Locks
A named lock lets one caller claim exclusive use of a key while others wait or back off. You pick the key — any app-scoped string, like portfolio-import:{userId} — and every acquirer of that key across your app is serialized against each other: your client code and a server function coordinate through the same namespace. Use it whenever two operations must not run at once: an import that would double a user's data if it overlapped itself, a "rebuild once" job, a webhook that must process one event at a time per resource.
Every lock is a lease: you acquire it for a bounded TTL, and if the holder crashes or disappears, the lease simply expires and the next acquirer takes over. Nothing wedges the key forever. Locks are cooperative — they coordinate willing participants, and holding one grants no rights over your data (use access control for that). Who may take which key is its own question, answered by Access Control below.
When to Reach for a Lock
A lock is a last resort, not the default way to stop concurrent work from colliding. Before reaching for one, check whether you can avoid the collision instead:
- Partition the work so concurrent workers can't select the same rows in the first place — shard a batch by user, resource, or another stable key so two workers never pick up the same item.
- Make the write idempotent, so a retry or an accidental duplicate attempt is harmless rather than something you need to serialize against.
- Guard the write with a conditional write — a field-equality precondition (commonly a
versionfield) that only lets the write through if the record still matches what you read. This is often the actual integrity boundary a lock would otherwise stand in for: several writers can all attempt the same record, and the database — not a held lock — ensures exactly one of them succeeds. - For scheduled work, use a cron trigger's
overlapPolicyinstead of locking inside the job body — the default"skip"already refuses to start a firing while the previous one is still running. A server function's cron trigger carries it (see Cron triggers).
Reach for a lock once none of these fit — typically a critical section that spans multiple independent writes, or non-database work (an external API call, a multi-step task) that has to run exclusively. Even then, a lock is a lease, not a correctness guarantee for the whole section: see Sizing the Lease for what happens when it expires before the work does.
Acquiring and Releasing
client.locks.acquire() blocks until it wins the key or the acquire timeout elapses, then throws a lock-timeout error. The returned handle carries its own key, so release() needs nothing else. Always release in a finally so a thrown step still frees the key:
const handle = await client.locks
.acquire(`portfolio-import:${userId}`, {
ttlMs: 60_000, // lease long enough to cover the work
timeoutMs: 10_000, // give up waiting after 10s
})
.catch((err) => {
if (err instanceof LockTimeoutError) return null; // already running
throw err;
});
if (!handle) return { started: false };
try {
// ... do the exclusive work here ...
} finally {
await client.locks.release(handle);
}
return { started: true };let handle: LockHandle
do {
handle = try await client.locks.acquire(
key: "portfolio-import:\(userId)",
ttl: 60, // seconds — lease long enough to cover the work
timeout: 10 // seconds — give up waiting after 10s
)
} catch let error as JsBaoError where error.code == .lockTimeout {
return false // already running
}
do {
try await work()
} catch {
_ = try? await client.locks.release(handle)
throw error
}
_ = try? await client.locks.release(handle)
return trueThe TTL is the lease — how long you hold the key before it expires — and the acquire timeout is how long you're willing to wait for a contended key before giving up. Both are required on a blocking acquire.
LockTimeoutError carries the contended key and the timeoutMs that elapsed, and has code: "LOCK_TIMEOUT" — branch on it to skip or reschedule the work rather than treating a busy key as a failure. In Swift the same timeout arrives as a JsBaoError with code == .lockTimeout, carrying the same key and timeoutMs in its details.
Trying Without Waiting
When you'd rather skip the work than wait, tryAcquire() makes a single attempt and returns null if the key is already held:
const handle = await client.locks.tryAcquire(`refresh:${userId}`, {
ttlMs: 30_000,
});
if (!handle) return; // someone else holds it — nothing to do
try {
// ... do the exclusive work here ...
} finally {
await client.locks.release(handle);
}guard let handle = try await client.locks.tryAcquire(
key: "refresh:\(userId)",
ttl: 30 // seconds
) else {
return // someone else holds it — nothing to do
}
do {
try await work()
} catch {
_ = try? await client.locks.release(handle)
throw error
}
_ = try? await client.locks.release(handle)Renewing and Inspecting
If your work might outlast the lease, extend it with client.locks.renew(handle, { ttlMs }) before it expires — renew() reports renewed: false if the handle no longer matches, which means the lease already lapsed and someone else took the key. client.locks.status(key) reports the current holder without trying to acquire, and returns held: false once a lease has expired even if the holder never released:
const status = await client.locks.status(key);
if (status.held) {
console.log(`held by ${status.heldBy}, lease expires ${status.leaseExpiresAt}`);
} else {
console.log("free");
}let status = try await client.locks.status(key: key)
if status.held {
print("held by \(status.heldBy ?? "?"), lease expires \(status.leaseExpiresAt ?? "?")")
} else {
print("free")
}Sizing the Lease
The lease does not renew itself. Size ttlMs to comfortably cover the work you do while holding the lock — if the lease expires mid-operation, another acquirer can take the key and run concurrently with you, which is exactly what the lock exists to prevent. When the work is long or its duration is unpredictable, either set a generous ttlMs or call renew() periodically to extend the lease as you go. The service caps a lease at 24 hours.
Re-taking Your Own Lease
A handle is the only thing that frees a lock, so a caller that loses its handle — a task run the platform resets partway through, a process that restarts — cannot release the key it holds and cannot acquire it either. It ends up waiting out its own lease, or giving up.
Name an owner when you acquire, and you can take your own lease back:
const handle = await client.locks.tryAcquire(key, {
ttlMs: 60_000,
owner: myRunId,
});Presenting the owner that already holds the key succeeds. You get a fresh handle and a fresh lease, and the handle the previous acquire minted stops working: release() on it reports not_holder and renew() reports lease_lost. That is deliberate. The key has exactly one current handle at every instant, and every handle a re-take replaced is fenced, so the acquire that lost the key cannot release it out from under you or renew a lease it no longer owns.
What re-taking does not do is stop code that is already running. A lock is a lease on a key, not a way to interrupt a process: if the previous holder is still executing — rather than reset, crashed or finished — it keeps going until it next talks to the lock and is told it is no longer the holder. Re-take when you know the earlier attempt is gone, which is what owner: ctx.runId inside a task run describes.
Three things have to match, not just the owner:
- the same principal — the same signed-in user or the same function;
- the same kind of caller — a function's hold is re-taken by that function's run, and a member's own hold by that member. The owner shows up on
status(), so without this rule anyone who could read it could take over the hold. A member who starts a task cannot re-take or rotate the lease their function holds; - the same owner string, exactly.
Anything else is refused with the ordinary contention shape. A hold made without an owner is never re-entered at all, so leaving owner out keeps the strictly non-reentrant behaviour.
What to use as the owner. Name the run: inside a server function that is ctx.runId, and when a run key coalesces several triggers into one run, name the work that key stands for. Never a static string like "importer" — two unrelated callers presenting it would re-enter each other's hold, which is the opposite of a lock.
status() reports the owner, so a caller that is refused can tell whose hold it is — its own successor's, or somebody else's:
const held = await client.locks.status(key);
if (held.held && held.owner === myRunId) {
// My own earlier attempt still holds it; re-acquire and carry on.
}Access Control
Lock keys share one app-wide namespace, so by default any signed-in member may acquire any key — including a key your server functions depend on. That is the shipped behaviour, and it stays until you say otherwise. To restrict it, install a lock rule set: one CEL rule per operation, matched against the key the caller asked for.
# primitive/dev/rule-sets/lock-policy.toml
[ruleSet]
name = "lock-policy"
resourceType = "lock"
[rules.lock]
# Members may only take keys inside their own namespace; `jobs:` keys are
# reserved for server-side work (server functions act with the app's own
# authority and are never subject to this rule).
acquire = "record.key.startsWith('user:' + user.userId + ':')"
renew = "record.key.startsWith('user:' + user.userId + ':')"primitive config push --only rule-set/lock-policyWhat a rule sees:
user.userIdanduser.role— the caller.record.key— the exact key from the request, so a rule can scope by namespace (record.key.startsWith('jobs:')), by caller prefix, or by group membership (isMemberOf('ops', 'core')).
The rules that decide a request, in order:
- App admins and owners always pass. As everywhere else on the platform, rules govern regular members.
- No
lockrule set installed → any member may operate on any key. This is the explicit default. Installing a rule set is how you opt in to lockdown; deleting it restores the open posture. - With a rule set installed, every operation it does not define is denied. A set that names only
acquiredeniesrenewfor members. Installing the set is the opt-in, so there is no partial coverage to be surprised by. - An app may install at most one
lockrule set — the policy is app-wide, and a second one would make "which policy is in force" ambiguous.
A denied call answers 403 with errorCode: "LOCK_ACCESS_DENIED". The response never echoes the rule.
Releasing is never rule-gated. Every release presents the handle minted at acquire, and the service frees the key only if that handle still matches — so possessing the handle already proves you are the holder. This matters when a policy changes underneath a running holder: if you revoke a member's access mid-hold, their renew starts failing (the hold can never be extended), but release keeps working, so they can free the key immediately instead of leaving it wedged until the lease expires. If they never release, the lease reclaims the key on its own.
Server functions are unaffected: a function's lock calls carry the app's own authority, and who may run the function is its own access gate. locks/status stays readable by any member, and locks list remains admin-only.
Inspecting Locks From the CLI
The primitive locks commands inspect and script the same lock namespace — handy for operations and debugging:
primitive locks list # every held lock in the app (admin)
primitive locks status portfolio-import:user-123 # who holds one key
primitive locks acquire portfolio-import:user-123 --ttl 60000 # one non-blocking attempt
primitive locks release portfolio-import:user-123 --handle 01HXY... # free it with the handle
primitive locks acquire portfolio-import:user-123 --owner run-01M2H5EYQQ # naming an ownerlocks acquire makes a single non-blocking attempt (like tryAcquire) and prints the handle to pass to locks release. --owner names the owner described in Re-taking Your Own Lease; locks status prints Owner and locks list carries an OWNER column, so you can see whose hold a key is under. locks list requires an app admin token; the other commands are available to any member.
Locking From a Server Function
A server function takes a lock through the same API, as ctx.api.locks.tryAcquire, ctx.api.locks.renew, ctx.api.locks.release and ctx.api.locks.status. A function's calls carry the app's own authority, so a lock rule set never refuses them. Inside a task run the one rule to get right is to acquire and release live on every slice, outside step.do, naming the run as the owner — see Locks from a function for the pattern.
Rate Limiting
Acquire attempts are capped at 600 per user per hour. A blocking acquire() counts each poll against this limit, and handles a rate-limit response internally — it keeps waiting within your acquire-timeout budget and raises the lock-timeout error if it never wins, rather than surfacing the limit. A single non-blocking tryAcquire() that exceeds the limit surfaces the rate-limit error to you directly.