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.
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 adds Polly-style addRetry() / addTimeout() / … shortcuts on top, at the cost of
bundling every strategy. Strategies run in the order they are added: the first one added is the outermost.
Options are validated and strategy state (e.g. circuit breaker) is created on build().
ResiliencePipelineBuilder adds Polly-style addRetry() / addTimeout() / … shortcuts on top, at the cost of
bundling every strategy. Strategies run in the order they are added: the first one added is the outermost.
Options are validated and strategy state (e.g. circuit breaker) is created on build().
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.
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.
Run. The callback runs with the lease’s fencing token in its context (getLockToken()).
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.
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.
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.
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().
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.
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.
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
interfaceMyLockStore
MyLockStoreextends
(alias) interfaceLockStore
importLockStore
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. */
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):
interfacePromise<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.