@zakkster/lite-di-lock
v1.0.0
Published
DI-scoped lock lifecycle over a pluggable store: positive-whitelist acquire, fail-closed fencing token, RAII release via a child scope, and a heartbeat lease. Zero deps; ships an in-process store and a validated Store Adapter interface.
Maintainers
Readme
@zakkster/lite-di-lock
A DI-scoped lock LIFECYCLE over a pluggable store. Positive-whitelist acquire, a fail-closed fencing token injected into a child DI scope, RAII release via the lock's own try/finally, and a chained one-shot heartbeat lease. Ships an in-process MemoryStore plus a validated three-method Store Adapter interface. Zero deps, single file, zero allocation on the hot lane.
The lock the DI ecosystem was missing
lite-di-lock is the mutual-exclusion / lease hinge of the @zakkster/lite-di-* service-kernel line. The container OWNS graph teardown, the supervisor HEALS, health REPORTS, the orchestrator RETIRES on SIGTERM. Nothing guarded a CRITICAL SECTION -- the one holder that may run a nightly report, a migration, or a leader-only loop -- with a FENCING lease that downstream resources can use to reject a stale holder, and that retires through the container's own reverse-topological shutdown. This is that piece.
It is deliberately NOT a distributed lock primitive. It is the LIFECYCLE (acquire -> hold -> heartbeat -> release/lose); the STORE is the primitive. Ship the same lifecycle over an in-process MemoryStore, a Redis lease, or an etcd key by implementing three methods.
npm install @zakkster/lite-di-lockimport { Container } from '@zakkster/lite-di-container';
import { Lock } from '@zakkster/lite-di-lock';
const c = new Container();
c.singleton('db', Db);
c.boot();
// key + ttlMs are required; the store defaults to a fresh in-process MemoryStore.
const lock = new Lock(c, { key: 'nightly-report', ttlMs: 30000 });
// RAII: acquire -> fenced child scope -> body -> release, all fail-closed.
await lock.run(async (scope) => {
const fence = scope.get('lock:fence'); // guaranteed an integer >= 1, or the body never ran
const db = scope.get('db'); // resolves through the parent container
await writeReport(db, { fence }); // pass the fence downstream to reject stale writers
});
// The lease is released and the child scope is torn down even if the body threw.One acquire that fails CLOSED against every malformed store response, one fencing token that is provably present or the work never ran, one RAII path that always releases. Zero allocation on the getter lane a supervisor or readiness probe reads often.
Table of contents
- Why this exists
- What you get
- The lock lifecycle
- API reference
- The fencing token contract
- Composability with the ecosystem
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
A lock has two failure modes that small libraries get wrong at the same time.
The first is the PHANTOM LOCK. A store returns some truthy blob and the client treats it as "held". The reference sketch this package supersedes used a naive res !== false && res !== null recognizer -- which wrongly grants on undefined, {}, 'ok', and every other truthy-but-malformed shape. The result is two holders that both think they own the section. lite-di-lock inverts the default: acquire is a POSITIVE whitelist. A response is a grant ONLY if it is { ok:true, token:<integer >= 1> } whose token strictly advances. Everything else -- false, null, undefined, a bare true/1/'ok', [], {}, a missing/zero/negative/fractional/NaN/string token, a sync throw, a rejected promise, a thenable whose .then getter throws, a resolved grant whose .ok/.token getter throws -- is no grant, and the machine stays IDLE with fencingToken 0.
The second is the UNFENCED WRITE. The holder does its work, but by the time the write lands the lease has silently expired and another holder has taken over. Both writes hit the resource. The fix is a FENCING TOKEN: a monotonically increasing number issued with the lease, passed downstream, so the resource can reject a write carrying a stale token. lite-di-lock injects that token into a child DI scope BEFORE the body runs and fails CLOSED at injection: if the scope or the bind throws, the lock is released and the error rethrown -- the body never runs unfenced. Protected work reads scope.get('lock:fence') and is guaranteed an integer >= 1, or it never ran.
What you get
- A positive-whitelist acquire. One pure
grantOfdecides grant-ness; no caller re-derives it. The store call, itsawait, and the whitelist inspection all sit inside ONE try/catch, so even a grant object with a throwing getter maps to no-grant instead of wedging the machine. - A fail-closed fencing token injected into a child
container.scope(), plus the lock itself injected as the holder so work can pollholder.held/holder.fencingTokenat 0 B. - RAII via the lock's OWN try/finally.
run(fn)tears the child scope down BEFORE releasing (scoped resources die while the fence is valid), runs both finally legs even when the body threw, and aggregates a finally-leg failure with the primary error viaAggregateError-- never masking it. - A chained one-shot heartbeat (never
setInterval) that fails closed to LOST the instant a renew is anything but a stricttrue, firingonLost(reason)exactly once through a single funnel. - A shipped in-process
MemoryStorewith monotonic per-key fencing tokens and TTL against an injected clock, plus a validated three-method Store Adapter interface for Redis/etc. bindLockso the held resource retires throughcontainer.shutdown()in reverse-topological order, with no leaked timer or lease.- Zero allocation on the
state/held/fencingTokengetters (0.000 B/op, hard-gated).
The lock lifecycle
IDLE -acquire-> ACQUIRING -grant-> HELD (heartbeat armed)
ACQUIRING -no grant-> IDLE (acquire() resolves false)
HELD -renew !== true-> LOST (onLost(reason); heartbeat disarmed)
HELD -release-> RELEASING -> RELEASED (disarm FIRST, then store.release)
LOST -release/dispose-> RELEASED
any -dispose-> RELEASED (idempotent terminal)acquire() is NON-REENTRANT: called from ACQUIRING / HELD / RELEASING / LOST it throws. release() from IDLE resolves false (nothing to release), never a fake success.
API reference
new Lock(container, options)
container is REQUIRED: a non-null object exposing scope(), value(), and shutdown() functions (else TypeError). options:
| option | default | contract |
| ------------- | -------------------------------- | --------------------------------------------------------------- |
| store | a fresh MemoryStore | a Store Adapter (acquire/renew/release functions) |
| key | -- (REQUIRED) | a non-empty string or a symbol |
| ttlMs | -- (REQUIRED, no default) | a finite number > 0 |
| heartbeatMs | ttlMs/3 | a finite number > 0; must satisfy heartbeatMs * 2 <= ttlMs |
| now | performance.now ?? Date.now | a () => number clock |
| timers | host { setTimeout, clearTimeout } | an injectable timer surface |
| tokenName | 'lock:fence' | the child-scope binding name for the fencing token |
| holderName | 'lock:holder' | the child-scope binding name for the lock holder |
| onLost | no-op | (reason:int) => void, invoked once when a held lease is lost |
An unknown option key throws with a did-you-mean hint.
Methods (all COLD, all async except the getters):
acquire(): Promise<boolean>-- true ONLY on a whitelisted, fence-advancing grant. Any non-grant resolves false with state IDLE and token 0.release(): Promise<boolean>-- true ONLY on a strict=== truerelease ack. From IDLE resolves false. Idempotent; disarms the heartbeat first.run(fn): Promise<any>-- RAII.acquire-> child scope (fence + holder bound) ->fn(scope)->finally { child.shutdown(); release() }. A non-grant throws NotAcquired (fn never runs). A fence-bind throw releases and rethrows. On a body throw the ORIGINAL error propagates; anAggregateErroris thrown ONLY when a finally leg also fails.tick(): Promise<boolean>-- the headless heartbeat the timer calls. A renew that is anything but stricttrueloses the lease to LOST and firesonLostonce. Re-arms only while HELD. After RELEASED/dispose returns false and never touches the store.dispose(): Promise<void>-- idempotent terminal. Releases a held lease, disarms, drops the container/store references.
Hot getters (0.000 B/op each):
state: number-- one ofSTATES.held: boolean--state === HELD.fencingToken: number-- the token, or0(nevernull) when unheld.
The Store Adapter
A store is any object exposing exactly three methods, validated to be functions at construction (a store missing any one -- e.g. renew -- is rejected at the door):
store.acquire(key, ttlMs) -> Promise<{ ok:true, token:int>=1 } | any>
store.renew(key, token, ttlMs) -> Promise<true | any>
store.release(key, token) -> Promise<true | any>The ONLY recognized grant shape is { ok:true, token:<integer >= 1> } (strict ok === true, Number.isInteger && >= 1). renew/release recognize a strict === true. The token is issued ATOMICALLY by acquire -- there is no separate fence() round-trip.
MemoryStore
The shipped in-process, zero-dependency adapter. Backs a single process with a Map<key, { token, expiresAt }> and a per-key monotonic INCR counter: acquire grants iff the key is free or its lease has EXPIRED against the injected clock, and every grant strictly bumps the counter (an expired-lease steal bumps it too), so a token never repeats. renew/release verify the token matches the current holder and return a strict boolean.
import { MemoryStore } from '@zakkster/lite-di-lock';
const store = new MemoryStore(); // uses performance.now ?? Date.now
const store2 = new MemoryStore({ now: () => 0 }); // deterministic clock for testsbindLock
import { bindLock } from '@zakkster/lite-di-lock';
bindLock(container, 'lock', lock);Registers singletonFactory(name, () => lock) + onTeardown(name, () => lock.dispose()). A cached factory is walked by container.shutdown() in reverse resolution order, so the lock disposes at the right moment. Throws if name is already registered (never a silent shadow). A value() registration is deliberately NOT used -- a VALUE is never walked by shutdown, so a lock bound as a value would never dispose.
Constants
STATES (frozen): IDLE:0, ACQUIRING:1, HELD:2, RELEASING:3, RELEASED:4, LOST:5.
REASONS (frozen): NONE:0, RELEASED:1, HEARTBEAT_FAILED:2, STORE_ERROR:3, FENCE_REGRESSED:4, DISPOSED:5, TIMER_UNARMABLE:6.
VERSION: the three-place-synced version string.
The fencing token contract
fencingToken returns the hoisted int 0 (never null) when unheld, so the hot getter stays branch-light; 0 is safe because the whitelist rejects 0 as a fence. The "null is not zero" law is honored at the INJECTION boundary, which throws rather than binding an unverified token.
Each grant's token must STRICTLY exceed the last token this lock accepted. A store replaying a non-advancing token (a broken store emitting constant 1, say) is refused with REASONS.FENCE_REGRESSED. A fencing token that can go backwards is not a fencing token; monotonicity is what lets a downstream resource reject a write from a stale lease holder.
Composability with the ecosystem
import { Container } from '@zakkster/lite-di-container';
import { Lock, MemoryStore, bindLock } from '@zakkster/lite-di-lock';
const c = new Container();
c.singleton('db', Db);
c.boot();
const lock = new Lock(c, { key: 'migrations', ttlMs: 60000, store: new MemoryStore() });
bindLock(c, 'lock', lock); // container.shutdown() disposes the lock in reverse-topo order
await lock.run((scope) => runMigrations(scope.get('db'), scope.get('lock:fence')));
await c.shutdown(); // tears down db AND disposes the lock; no leaked timer or leaseInside run(fn) the body receives a real child scope: scope.get('lock:fence') is the token, scope.get('lock:holder') is the lock (poll holder.held at 0 B), and any parent-only registration (db) resolves through the scope chain. After run() the parent's live-child count is back to baseline and parent.shutdown() succeeds.
Zero-GC design notes
The only steady-state hot lane is the getter trio. Everything else (acquire, run, release, dispose) is COLD -- a lifecycle runs a bounded number of times, so allocation on those paths is fine and is proven not to accrete by the retention soak.
| lane | budget | measured | how it is gated |
| --------------------------------- | --------------- | -------------- | -------------------------------------------------------------------- |
| get held + get fencingToken | 0 B/op (hard) | 0.000 B/op | measureAllocs maxBytesPerCall 0 AND measureOps stabilize:'deep', over 1e6 reads across a HELD and a RELEASED instance (no constant-fold) |
| tick() heartbeat (retained) | <= 64 B/beat | ~0.4 B/beat | measureOpsAsync over 1e5 beats, steady major 0, maxPauseMs <= 2.0, with an always-on self-proof that a retained per-beat regression trips the gate |
| soak heap delta (1e4 lifecycles) | < 64 KB | ~9 KB | 1e4 full lifecycles behind a 1000-cycle warmup; lite-leak size 0, every MemoryStore drained, timer census 0 |
Design choices that keep the getters at 0 B: the state is a hoisted int compared against module-level constants; the unheld fencing token is the int 0, not null, so the getter is a bare property read with no branch; every field is assigned unconditionally in the constructor for one stable hidden class; and the heartbeat callback is bound ONCE in the constructor, never per tick.
A note on honesty: the heartbeat is inherently async (a promise per await), so its per-beat gate measures RETAINED bytes -- the real leak risk. A transient allocation is physically collected out of a live-set delta and cannot be gated by bytes/beat; unbounded transient churn is bounded instead by the steady major === 0 / maxPauseMs <= 2.0 GC-pressure gate.
Design decisions worth knowing
- Positive whitelist, never a blacklist. A new adversarial store shape fails CLOSED because a shape is a grant only if it matches. See decision 0001.
- The fence fails closed at injection.
run(fn)binds the token only after a real grant; a scope/bind throw releases and rethrows. The body runs fenced or never runs. - RAII via the lock's own try/finally, not
value()teardown:child.shutdown()beforerelease(), both legs always run,AggregateErroronly on a double fault. - Non-reentrant, single-funnel loss.
_loseis the one seam a futurecontroller.abort()will land in; the alpha ships the seam (injected holder +onLost) and defers theAbortSignal. - Uniform async.
MemoryStorereturns already-resolved promises -- no sync fast path a test could miss.
Testing
node:test unit suites plus a single torture gate that is the real proof:
npm test # node --test test/*.test.js
npm run torture # node --expose-gc test/torture.mjs -> prints "ok" + a GATE line
npm run verify # test + tortureThe torture gate runs nine strictly-sequential tiers: laws + option fail-closed + ASCII control, the positive-whitelist matrix (23 adversarial rows), the fencing token (injection + monotonicity), the lifecycle machine, RAII on throw, the 0 B getter lane + heartbeat ceiling, a 1e4-lifecycle retention soak, a money composition against the REAL @zakkster/[email protected], and controls that prove every gate can fail. Three env controls (DI_ASCII_BREAK, DI_ALLOC_BREAK, DI_TORTURE_BREAK) each make the whole suite exit non-zero.
What this is not
- A distributed consensus / lock SERVICE (Redlock, etcd, ZooKeeper). This is the LIFECYCLE that drives one; you implement the three-method adapter over that service.
- A DI container. That is
@zakkster/lite-di-container; this locks a section within one and retires through itsshutdown(). - A self-healing supervisor. That is
@zakkster/lite-di-supervisor; a lock guards a section, it does not restart failed children. - A graceful-shutdown orchestrator. That is
@zakkster/lite-di-orchestrator; this disposes via the container it retires last. - A semaphore with N permits. This is a single-holder mutual-exclusion lease; N-holder permits are out of scope for the alpha.
Ecosystem
Part of the @zakkster/lite-di-* service-kernel line: lite-di-container (the graph + teardown), lite-di-supervisor (self-healing), lite-di-health (readiness), lite-di-orchestrator (graceful shutdown), and this, lite-di-lock (the mutual-exclusion / lease hinge). Proven zero-GC with @zakkster/lite-gc-profiler and @zakkster/lite-leak.
License
MIT (c) Zahary Shinikchiev [email protected]
