@dokebi/lock
v0.4.1
Published
Named lock backends for Dokebi with fencing tokens: in-process or Redis leases.
Downloads
516
Maintainers
Readme
@dokebi/lock
An optional per-app lock plugin. Each backend picks a driver: local (default, exclusion inside one event loop) or redis (a lease shared by processes and servers; needs @dokebi/lock-redis). core ^0.4.0, Node.js 24.0.0 or later, version 0.4.1 (unpublished). Configure it under options.dokebi-lock.backends in the single YAML. AI agents start with AI.md.
schemaVersion: 1
options:
dokebi-lock:
backends:
orders:
driver: local # may be omitted
maxKeys: 10000
maxWaiters: 128
waitTimeoutMs: 5000
maxKeyBytes: 1024
jobs: {}
shared:
driver: redis
connection: main # a name from dokebi-redis.connections
namespace: my-service
leaseMs: 10000Registration and use
- The factory input is
{ app, options?, entry? }(entryis the module of the entry filesrc/infra/lock/extension.ts, see "Extension" below).createPlugin({ app: app.view })readsapp.config['dokebi-lock']; givenoptionsare used instead. Invalid options areINVALID_PLUGIN_OPTIONS(cause: the coreINVALID_OPTIONS, paths indetails.issues). The options are onedefineOptionsdeclaration insrc/options.ts, and the build generatesoptions.schema.jsonfrom it (D-0063). - Register, start, then get
app.get(plugin).backend('orders'). A consumer plugin putsimport { dependency } from '@dokebi/lock'inmetadata.dependenciesand usescontext.getDependency(dependency).
const lease = await backend.acquire('order:42', { waitTimeoutMs: 5000 });
try {
// critical section for this backend and key. Pass lease.fence along with writes to an outside store.
} finally {
await lease.release();
}Lease contract (both drivers)
| Field | local | redis |
|---|---|---|
| key | the acquired key | same |
| valid | true until release or shutdown | a conservative local estimate; false after expiry, release or an unknown outcome |
| remainingMs | Number.POSITIVE_INFINITY (0 when invalid) | remaining lease time |
| fence | decimal string; grows with each acquire in the backend | decimal string; grows per key with Redis INCR |
| renew() | returns valid | extends the TTL after comparing the owner token |
| release() | true when ownership was handed on, false when already released | deletes after comparing the owner token |
Fencing tokens
fencegrows with each acquire of the same backend and key (not necessarily by one). local shares one counter per backend, so it does not remember released keys. redis keeps the counter<namespace>:lock-fence:<sha256(key)>without expiry (one Redis key stays per unique key).- Without the downstream store verifying the fencing token atomically, mutual exclusion is not guaranteed under GC pauses or delays. Example:
UPDATE … SET fence = $1 WHERE fence < $1. Writing the value without comparing does not stop the late write of an expired former owner. - The token is a string. Compare with
BigInt(fence).
Drivers
| Key (local) | Type | Default | Description |
|---|---|---|---|
| maxKeys | integer | 10000 | Keys held at once |
| maxWaiters | integer | 128 | Waiters of the backend |
| waitTimeoutMs | integer ms | 5000 | Default wait |
| maxKeyBytes | integer | 1024 | Key UTF-8 bytes |
driver: local: the provider offersnames/backend(name)and the backend offersacquire(key, { signal?, waitTimeoutMs? }). The same key is FIFO and different keys can be held at once.maxWaiterscounts waiters of the whole backend,maxKeysthe keys being held.waitTimeoutMs=0tries at once, thenLOCK_BUSY; withmaxWaiters=0a free key can still be acquired at once. An acquired lock never expires or releases on its own, so release it infinally. Not shared across apps, backends, threads or processes.driver: redis: fieldsconnection,namespace,leaseMs(required),waitTimeoutMs(5000),pollIntervalMs(25),notifyPollIntervalMs(250, the safety-net retry while release notifications are live, D-0058),maxWaiters(4096, 1–100000: every acquire in progress counts, D-0106),maxLeases(10000),maxKeyBytes(1024),shutdownTimeoutMs(5000). Waiting for the same key is FIFO only inside a process (no order across servers). Behavior is described in @dokebi/lock-redis.- With at least one redis backend,
dokebi-redis ^0.4.0becomes a runtime dependency. When not registered, it is created from the@dokebi/redispackage. With local only there is no dependency and the Redis package is never loaded. - The driver package is imported in setup. When it is not installed, start fails with
LockErrorDRIVER_NOT_INSTALLEDand thefixsaysnpm install @dokebi/lock-redis. A missing connection name isUNKNOWN_CONNECTION. - The driver contract is
LockDriver { view: LockBackend; close(): Promise<void> }.
Extension (D-0064)
dokebi addcreates the user-owned entry filesrc/infra/lock/extension.ts(an emptyLockExtensionsubclass). Left empty, behavior is the default and costs nothing (hooks you did not override are never called).- Hooks (
protected override, three):dependencies(),createDriver(input),wrapBackend(input). Constructor parameters or shadowing a non-hook member fail withINVALID_EXTENSION(fix,data.reason) when the plugin is created. - Driver registry: the built-in
localandredisstay, and app drivers are added withexport const drivers = { <name>: (input) => driver }in the entry file or withdefineLockPlugin({ drivers }). YAML usesdriver: <name>andsettings. Details in AI.md §8 andexamples/29-extension.
Install (CLI)
- Add it with
npx dokebi add @dokebi/lock.@dokebi/lock-redisis not a plugin; add it withnpm install. planInstallandcheckof the install module (dist/install.js) returnrequires: ['dokebi-redis']when a redis backend exists.checkreports an error diagnosticDRIVER_PACKAGE_MISSINGper backend (pathbackends.<name>.driver) when package.json lacks@dokebi/lock-redis.- The manifest is schemaVersion 2 and declares
dokebi-redisas an optional dependency.@dokebi/redisand@dokebi/lock-redisare optional peerDependencies.
Shutdown and logs
signalapplies only before acquiring and while waiting. After the consumers' reverse-order cleanup the provider closes the drivers. local rejects waiters and invalidates held leases; redis releases held leases within a bounded deadline. User code is never stopped forcibly. Afterwardsbackend(name)isCLOSED.- Logs: debug
LOCK_BACKEND_READYwhen a backend is ready, error when a driver close fails (the driver's error code such asCLEANUP_TIMEOUTorCLEANUP_FAILED, orLOCK_CLOSE_FAILED). Fields arebackendandcodeonly. - Only well-formed, non-empty UTF-16 keys are accepted, with a limited UTF-8 length.
Main LockError.code values: INVALID_OPTIONS, INVALID_KEY, UNKNOWN_BACKEND, UNKNOWN_CONNECTION, CLOSED, LOCK_BUSY, KEY_LIMIT, QUEUE_FULL, ABORTED, WAIT_TIMEOUT, DRIVER_NOT_INSTALLED, DRIVER_INVALID, (redis) PROVIDER_FAILED, OUTCOME_UNKNOWN, LEASE_LOST, CLEANUP_FAILED, CLEANUP_TIMEOUT (with the Redis error as cause). Through dokebi-web QUEUE_FULL/KEY_LIMIT answer 503 LOCK_OVERLOADED and CLOSED 503 LOCK_UNAVAILABLE (Retry-After: 1, D-0106); a transient Redis cause answers @dokebi/redis's 503 (D-0102). LockError is a DokebiError (plugin: 'dokebi-lock', fix), and other packages classify it by code. Extension: UNKNOWN_DRIVER, DRIVER_CONFLICT, INVALID_ENTRY, INVALID_EXTENSION. Keys and original data never appear in errors.
Runnable examples: example 14 (local), example 15 (redis), and for extensions examples/29-extension.
