Skip to content

Lock

The lock strategy runs a callback only while it holds a lease on a key: a mutex across executions, and across processes with a shared store. Use it for work that must not run twice at the same time: a nightly job on a fleet of instances, a balance update per account, a cache rebuild. It isn’t a queue (waiters poll and the order isn’t guaranteed), and it isn’t for hot paths that need microsecond locking.

const
const pipeline: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
()
.
ResiliencePipelineBuilder<any, never, never>.addLock(options: LockOptions): Next<any, never, never, LockNotAcquiredError | LockLostError>

Run under a (distributed, with a custom store) lock: leases, renewal, fencing tokens.

addLock
({
LockOptions.key: (context: ResilienceContext) => string

Lock key for this execution, e.g. () => 'nightly-report' or (ctx) => \order:${ctx.operationKey}``.

key
: () => 'nightly-report',
LockOptions.ttl?: Duration | undefined

Lease length. Renewed every ttl/3 while the callback runs. Default 30 s.

ttl
: '30s' })
.
ResiliencePipelineBuilder<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>, never>.build(): ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
build
();
await
const pipeline: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
pipeline
.
ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>.execute<void>(callback: (context: ResilienceContext) => MaybePromise<void>, options?: ExecuteOptions): Promise<void>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function buildReport(signal: AbortSignal): Promise<void>
buildReport
(
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
)); // LockNotAcquiredError if another run holds it

Without a store, the lock only excludes executions in the same process. To lock across instances, pass a distributed store such as RedisLockStore from Redis.

0 ms2 s4 s6 srenew every ttl/3 = 1 s
lease heldrenewalGC pausewaiting for the locklost / rejected

    lockStrategy({ key: () => 'account:1', ttl: 3000 })
    1. Acquire. The strategy asks the store for a lease on key(context) for ttl, under a random owner id. If someone else holds it, it polls (50 ms, doubling up to 1 s) until wait runs out, then fails with LockNotAcquiredError. The default wait is 0: fail fast.
    2. Run. The callback runs with the lease’s fencing token in its context (getLockToken()).
    3. Renew. Every ttl / 3 the lease is pushed out to now + ttl. A failed renewal (store error) is retried while the current lease is still valid.
    4. Lose. If the store says the lease belongs to someone else, or it couldn’t be renewed before it expired, the callback’s signal is aborted with LockLostError, onLockLost is called, and a failing callback reports LockLostError.
    5. Release. When the callback ends (success or failure), the lease is released, but only if this owner still holds it. A failed release is ignored; the lease expires anyway.

    Why a lease and not a plain lock: a process that crashes while holding a plain lock holds it forever. A lease expires ttl after the last renewal. Renewal keeps long jobs alive without a long ttl, so a crash frees the key within one ttl.

    A lease alone is not enough. A process can pause (a GC pause, a VM migration, a slow network) for longer than ttl. Its lease expires, another process acquires the lock and writes, and then the first one wakes up and writes too, still believing it holds the lock. Abort signals don’t help: the paused process can’t see its signal while paused.

    The fix is a fencing token: a number that increases with every acquisition of a key. Pass it with every write, and let the protected resource reject writes carrying a token lower than one it has already seen.

    const
    const pipeline: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
    pipeline
    = new
    new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

    Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

    The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

    ResiliencePipelineBuilder
    ()
    .
    ResiliencePipelineBuilder<any, never, never>.addLock(options: LockOptions): Next<any, never, never, LockNotAcquiredError | LockLostError>

    Run under a (distributed, with a custom store) lock: leases, renewal, fencing tokens.

    addLock
    ({
    LockOptions.key: (context: ResilienceContext) => string

    Lock key for this execution, e.g. () => 'nightly-report' or (ctx) => \order:${ctx.operationKey}``.

    key
    : (
    ctx: ResilienceContext
    ctx
    ) => `account:${
    ctx: ResilienceContext
    ctx
    .
    ResilienceContext.operationKey: string | undefined
    operationKey
    }`,
    LockOptions.wait?: Duration | undefined

    How long to wait for a held lock before rejecting with LockNotAcquiredError. Default 0: fail fast.

    wait
    : '5s' })
    .
    ResiliencePipelineBuilder<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>, never>.build(): ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
    build
    ();
    async function
    function saveBalance(accountId: string, balance: number): Promise<void>
    saveBalance
    (
    accountId: string
    accountId
    : string,
    balance: number
    balance
    : number) {
    const
    const token: number | undefined
    token
    =
    function getLockToken(context?: ResilienceContext | undefined): number | undefined

    Fencing token of the innermost lock the calling code runs under. Pass it to the protected resource so it can reject stale writers.

    getLockToken
    (); // reads the ambient context of the running execution
    const {
    const rowCount: number
    rowCount
    } = await
    const db: {
    query(sql: string, params: unknown[]): Promise<{
    rowCount: number;
    }>;
    }
    db
    .
    function query(sql: string, params: unknown[]): Promise<{
    rowCount: number;
    }>
    query
    (
    'UPDATE accounts SET balance = $1, fence = $2 WHERE id = $3 AND fence < $2',
    [
    balance: number
    balance
    ,
    const token: number | undefined
    token
    ,
    accountId: string
    accountId
    ],
    );
    if (
    const rowCount: number
    rowCount
    === 0) throw new
    var Error: ErrorConstructor
    new (message?: string, options?: ErrorOptions) => Error (+1 overload)
    Error
    ('stale lock holder, write rejected');
    }
    await
    const pipeline: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
    pipeline
    .
    ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>.execute<void>(callback: (context: ResilienceContext) => MaybePromise<void>, options?: ExecuteOptions): Promise<void>

    Runs the callback through the pipeline; throws the final error.

    execute
    (() =>
    function saveBalance(accountId: string, balance: number): Promise<void>
    saveBalance
    ('acc_1', 100), {
    ExecuteOptions.operationKey?: string | undefined
    operationKey
    : 'acc_1' });

    getLockToken(context?) returns the token of the innermost lock the code runs under, or undefined outside a lock. Without an argument it reads the ambient context, so it works deep inside your call stack. The token is also in context.properties under LOCK_TOKEN.

    Option Type Default Description
    key (context) => string required Lock key for this execution, e.g. () => 'nightly-report' or (ctx) => `order:${ctx.operationKey}` .
    ttl Duration 30_000 (30 s) Lease length, renewed every ttl / 3 while the callback runs. > 0.
    wait Duration 0 (fail fast) How long to wait for a held lock before failing with LockNotAcquiredError.
    store LockStore one MemoryLockStore per clock, shared by every lock on the pipeline’s time provider (so one per process with the system clock, and leases expire on a FakeTimeProvider in tests) Where leases live. Use a distributed store to lock across processes.
    onLockLost (args: { context, key }) => void | Promise<void> – Called when renewal fails. The callback’s signal is then aborted.
    name string 'Lock' Strategy name.
    Error code HTTP When
    LockNotAcquiredError LOCK_NOT_ACQUIRED 409 The lock stayed held for longer than wait. Has key. A rejection: isRejection() matches, the callback never ran.
    LockLostError LOCK_LOST – The lease was lost while the callback ran, and the callback failed. Has key. Also the abort reason of the callback’s signal.

    If the callback ignores its signal and still succeeds after the lease was lost, the success is returned. Check signal.aborted before committing, or use fencing.

    Telemetry event Callback When
    OnLockRejected – wait ran out. Args: { context, key }.
    OnLockLost onLockLost Renewal failed. Args: { context, key }.

    Aborting the caller’s signal while waiting fails with the abort reason; the callback never runs.

    MemoryLockStore excludes executions inside one process. It takes an optional timeProvider (for tests). It keeps one token counter per key ever locked, so tokens stay monotonic; that’s fine unless your keys are unbounded.

    To plug in Redis, Postgres or anything else, implement LockStore. Every method must be atomic for its key:

    interface
    interface MyLockStore
    MyLockStore
    extends
    (alias) interface LockStore
    import LockStore

    Lease storage behind LockStrategy. Every method must be atomic for its key, so adapters (Redis, Postgres) can be swapped in:

    • acquire: if the key has no live lease, store { owner, expires: now + ttlMs }, bump the key's fencing token and return it; otherwise return undefined. Tokens must only ever increase per key, also across expiry and release.
    • renew: if the live lease belongs to owner, push its expiry to now + ttlMs and return true; otherwise false.
    • release: delete the lease only if it belongs to owner (never someone else's lease that took over after expiry).

    LockStore
    {
    /** No live lease: store { owner, expires: now + ttlMs }, bump the key's fencing token and return it. Else undefined. */
    MyLockStore.acquire(key: string, owner: string, ttlMs: number): Promise<{
    fencingToken: number;
    } | undefined>

    No live lease: store { owner, expires: now + ttlMs }, bump the key's fencing token and return it. Else undefined.

    acquire
    (
    key: string
    key
    : string,
    owner: string
    owner
    : string,
    ttlMs: number
    ttlMs
    : number):
    interface Promise<T>

    Represents the completion of an asynchronous operation

    Promise
    <{
    fencingToken: number
    fencingToken
    : number } | undefined>;
    /** The live lease belongs to `owner`: push its expiry to now + ttlMs and return true. Else false. */
    MyLockStore.renew(key: string, owner: string, ttlMs: number): Promise<boolean>

    The live lease belongs to owner: push its expiry to now + ttlMs and return true. Else false.

    renew
    (
    key: string
    key
    : string,
    owner: string
    owner
    : string,
    ttlMs: number
    ttlMs
    : number):
    interface Promise<T>

    Represents the completion of an asynchronous operation

    Promise
    <boolean>;
    /** Delete the lease only if it belongs to `owner` (never a lease someone else took after expiry). */
    MyLockStore.release(key: string, owner: string): Promise<void>

    Delete the lease only if it belongs to owner (never a lease someone else took after expiry).

    release
    (
    key: string
    key
    : string,
    owner: string
    owner
    : string):
    interface Promise<T>

    Represents the completion of an asynchronous operation

    Promise
    <void>;
    }

    Fencing tokens must only ever increase per key, also across expiry and release. RedisLockStore issues them atomically with the lease; a custom LockStore must give the same guarantee.

    • Choose ttl by pause length, not job length. Renewal handles long jobs. ttl bounds how long a crashed holder blocks others and how long a paused holder can go unnoticed. A few seconds to a minute is typical.
    • Pass the signal on. A lost lease aborts context.signal. I/O that ignores it keeps running without the lock.
    • Waiting is polling. A release is noticed up to 1 s late. Keep wait for occasional contention; for a work queue, use a queue.