Cross-run locks
A lock claims an exclusive resource across runs, processes, even machines — "one deploy of repo X at a time." It lives in the same state store as durable run state. Acquisition is contended-fails-fast: no queue, no waiting — the loser gets actionable guidance instead.
.lock() authoring
Call .lock(configure) on a target to guard it with a lock. The
configure lambda runs after params resolve, so the key can read
this.<param>.value:
// target.ts: lock(configure: Configure<LockSettings>): this
export class LockSettings {
lockKey(...parts: Array<string | number>): this; // sanitised + joined
key(key: string): this; // set directly (must be filename-safe)
withTtl(ttl: string | number): this; // "4h"/"30m" or ms
onConflict(render: (holder: LockHolder) => string): this;
} import { Build, parameter, target } from "jsr:@zuke/core";
class CD extends Build {
repo = parameter("service to deploy");
promote = target()
.lock((s) =>
s.lockKey("deploy", this.repo.value)
.withTtl("4h")
.onConflict((h) =>
`${this.repo.value} is being deployed by ${h.actor} ` +
`(run ${h.runId}, since ${h.since}). Wait, then retry.`))
.executes(async (ctx) => {/* … */});
} lockKey()
lockKey(...parts) builds a safe key by joining its parts with
-:
export function lockKey(...parts: Array<string | number>): string; // non-[A-Za-z0-9._-]→"_", drop empty, join "-"
Every character outside [A-Za-z0-9._-] becomes _
and empty parts are dropped, so s.lockKey("deploy", this.repo.value)
always yields a filename-safe key regardless of what the parameter holds.
LockHolder
export interface LockHolder { actor: string; runId: string; since: string; runUrl?: string }
export class LockConflictError extends Error { readonly holder: LockHolder; /* message = guidance */ }
export type LockResult = { ok: true; token: string } | { ok: false; holder: LockHolder }; LockHolder identifies who currently holds a lock — used both
in onConflict's guidance message and in the raw
LockResult a store returns on a failed acquisition.
TTL, heartbeat & release
.withTtl("4h" | "30m" | ms) bounds a lock whose holder
disappears — it is not a hard limit on the operation
itself. A live holder auto-renews the lock at half the
TTL, so a long-running deploy under a short TTL never loses its
lock while it's still alive. Kill the process (kill -9) and
renewals simply stop — the lock frees once the TTL elapses.
The lock is released in a finally on settle — success,
failure, or cancellation — so the TTL only backstops a killed process, it
isn't the normal release path. Acquisition is atomic: two runs racing a
free key resolve to exactly one winner, and an expired lock is taken over
atomically by the next acquirer.
LockConflictError & conflict output
There's no dedicated lock CLI. On a conflict, the target fails with a
LockConflictError whose message is the rendered
onConflict guidance; the run exits non-zero, the failure
footer prints that guidance, and the target is recorded failed
in the run record with the guidance as its error. Release a
lock held by a run you control with zuke cancel <runId>.
Requires a state store
Locks live in the state store — a build that calls .lock()
turns on the .zuke/runs filesystem store by default if
nothing else configured one. Calling .lock() with state
explicitly disabled fails with a friendly error rather than silently
not locking. Point the build at an HTTP-backed store (see
FileSystem vs HTTP backends) to share
locks across machines — the server is authoritative for expiry there.
The run's own lease
Separately from any lock a target declares, a run with a
state store holds a lease on
itself for as long as it is running: zuke-run-<run-id>,
taken before the record is written running and released when
the run settles. A background heartbeat renews it at half its 60-second TTL.
Its whole job is to make "slow" and "dead" different things.
A run record cannot tell them apart on its own — a process mid-step and a
process that was SIGKILLed both leave a record that says
running and stops changing. The lease answers it: a live holder
keeps renewing, so a claim that has lapsed means the holder is gone and the
run can be taken over.
- Ordering matters. The lease is taken before the
record says
running, and on a resume before the record leavessuspended— so arunningrecord always has a live holder. Acquiring afterwards would leave a window in which a perfectly healthy run looked abandoned. - A resume refuses a run whose holder has not let go. Two processes working one run is worse than a delayed resume, so the claim is checked before the compare-and-swap and a resumer that cannot take it stops.
- Losing it stops the run. If a renewal is refused — the claim is demonstrably somebody else's now — the run aborts rather than carrying on beside whoever took it over.
- A refused renewal is loss; a failed one is not. A store
answers "no" when the claim has changed hands, but it throws for
a filesystem mutex it could not take in time, an HTTP
503, or a DNS blip. None of those say who holds the lease, so they are retried on the next tick rather than aborting a healthy build over a bad second. - The heartbeat never keeps a process alive — and, like any timer, it does not fire while the event loop is blocked in synchronous work. A run that blocks for longer than the TTL can therefore have its lease lapse and be taken over; losing the claim then stops it, so the outcome is a stopped run rather than two writers.
- Whoever takes the claim gives it back. A resume releases the lease it took on every path out, including one where the run fails — a claim held by nobody would make a run that has demonstrably stopped look like one still being worked on.
- A crashed holder's claim lapses at the TTL. Nothing polls for it: expiry is evaluated by the store the next time somebody tries to acquire.
HTTP lock endpoints
| Verb + path | Behavior |
|---|---|
POST /locks/:key | 201 { token } on acquisition, or 409 plus the current LockHolder. |
PUT /locks/:key | Renew. 409/404 means the token was lost. |
DELETE /locks/:key | Release. 404 is not an error. |
Key safety
lockKey(...) always sanitizes its input — prefer it.
s.key(literal) sets the key directly and bypasses that
sanitization, so a caller using it is responsible for keeping the value
filename-safe.