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 leaves suspended — so a running record 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 + pathBehavior
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.