@dokebi/lock-redis
v0.4.1
Published
Redis lease driver for @dokebi/lock (not a plugin; install with npm).
Maintainers
Readme
@dokebi/lock-redis
The single-Redis lease driver of @dokebi/lock. It is not a plugin. Its package.json has no dokebi manifest, so dokebi add refuses it. Add it with npm install @dokebi/lock-redis. core ^0.4.0, Node 24.0.0 or later, version 0.4.1 (unpublished). AI agents start with AI.md.
Configuration
Write driver: redis on a dokebi-lock backend. The option schema and validation live in @dokebi/lock.
options:
dokebi-redis:
connections:
main:
url: redis://127.0.0.1:6379/0
dokebi-lock:
backends:
shared:
driver: redis
connection: main
namespace: my-service
leaseMs: 10000connection is a name that exists in options.dokebi-redis.connections. Addresses and namespaces never get invented defaults. Apps using the same connection, namespace and key share locks and fencing counters. dokebi-lock prepares the dokebi-redis dependency automatically and imports this package. Without it, dokebi-lock fails to start with DRIVER_NOT_INSTALLED and dokebi doctor reports DRIVER_PACKAGE_MISSING.
Public API
createRedisLockDriver(connection, options, logger?): LockDriver— dokebi-lock calls it.optionsis validated again withparseRedisLockBackend(from@dokebi/lock).- peerDependencies:
@dokebi/core,@dokebi/lock,@dokebi/redis.
Behavior
backend.acquire(key, { signal?, waitTimeoutMs? }) returns a lease with key/valid/remainingMs/fence/release()/renew(). Release it in finally. Renewal is explicit; there is no automatic renewal.
- Acquire is one Lua script: when
SET <namespace>:lock:{<sha256(key)>} token NX PX leaseMssucceeds it runsINCR <namespace>:lock-fence:{<sha256(key)>}and returns the value as a decimal string. When already held it returns the holder's remaining time (PTTL, integer ms; since 0.1.4, nil up to 0.1.3). No other acquire can slip in between acquiring and incrementing the fence. Both keys use the same hash tag{…}, so they share a slot in Redis Cluster too. - RELEASE and RENEW are Lua scripts that compare the owner token. Only when RELEASE deleted the key does it
PUBLISH <namespace>:lock-released <sha256(key)>in the same script (0.1.4; no extra round trip, nearly free without subscribers). All three scripts are created withRedisConnection.script()and run asEVALSHA, and are sent once more asEVALonly when the server lacks the script (NOSCRIPT). - The fencing counter key never expires; one stays in Redis per unique lock key. A missing counter (the first time, or after Redis lost data) starts at the Redis
TIMEmilliseconds × 1000 (0.1.2, D-0041), so new tokens are larger than old ones even after data loss. The conditions are fewer than 1000 acquires per ms on one key and a server clock that never goes back. Up to 0.1.1 it restarted from 1 (OPS-002 measurement). - Deadlines count from command submission, not from receiving the reply, and an expired or unknown-outcome state never becomes valid again.
The wait defaults to 5000 ms; 0 tries once, then LOCK_BUSY. Commands with an unsettled outcome are never retried automatically.
Waiting: release notifications and FIFO inside a process (0.1.4, D-0058)
- Waiters of this process (backend) for the same key line up, and only the first tries Redis. When the first one acquires or gives up, the next becomes first. However many waiters a key has, there is one Redis attempt per backend. Other keys have their own lines.
- When a waiter really starts waiting, the backend subscribes to
<namespace>:lock-released(subscribeof @dokebi/redis 0.2.2, one subscribe connection) and disconnects when no new waiter comes within 1 second after the last one left. Without contention it never subscribes. The moment the subscription is confirmed the first waiter checks once more (a release before the subscription sends no notification it could receive). - When a release notification arrives, the first waiter of that key tries again at once. A release by the same process also wakes waiters only through the notification (handing over inside the process first would starve other servers).
- From the PTTL of a busy reply it retries after
min(PTTL, safety interval, remaining wait). The lease of a holder that died without releasing passes on when it expires. - Safety interval:
notifyPollIntervalMs(default 250) while the subscription is live;pollIntervalMs(default 25) when there is no subscription (an older @dokebi/redis, debugLOCK_NOTIFY_UNAVAILABLEreason: unsupportedonce) or it failed (warnLOCK_NOTIFY_UNAVAILABLEonce withreason: failedand the full cause; it subscribes again when waiters return more than 5 seconds later). The safety net also covers missed notifications (while the subscription was down, or a release by an old-version process). signal,WAIT_TIMEOUT,QUEUE_FULL(maxWaiterscounts all acquires in progress on the backend) andLOCK_BUSYare per waiter. Cancellation or timeout of a waiter in the middle never disturbs the line.waitTimeoutMs: 0givesLOCK_BUSYat once without asking Redis when this process already has a waiter for the key (it never jumps the line).- Order across servers is not guaranteed: after a notification only the first waiter of each process competes. There is no wait line in Redis (cleaning up dead waiters would need constant heartbeat writes).
- Measurement (4 processes × 8 waiters, one key held 2 ms,
benchmarks/lock-contention.bench.mjs):benchmarks/results/2026-10-15-lock-contention-before.jsonand-after.json. Cancellation does not withdraw a command already submitted; when an acquire is confirmed after cancellation it is cleaned up by token comparison.
By default up to 128 waiters and 10000 tracked leases. Expired leases stay tracked until release or shutdown. Shutdown blocks new acquires, stops waits, then cleans up known tokens in parallel. shutdownTimeoutMs (default 5000) bounds the whole wait for acquires and releases in progress. Past it, it returns CLEANUP_TIMEOUT but keeps cleaning up owner tokens of late replies, and never reports unconfirmed cleanup as success. A cleanup failure is an error (dokebi-lock logs it at error) and the TTL is the final expiry.
Limits of fencing
A single-Redis lease; no Redlock and no global FIFO (FIFO only inside a process). valid is a conservative local estimate. Without the downstream store verifying fencing tokens atomically, mutual exclusion is not guaranteed under GC pauses or delays. A counter going back through Redis failover (replication lag) is outside the guarantee too. Deadlines, cancellation and idempotency of user work are the consumer's responsibility.
Error classification
- Redis errors are classified by structure, not
instanceof:error.plugin === 'dokebi-redis' && error.outcome === 'not-submitted'orcode === 'COMMAND_REJECTED'(a server error reply, D-0049) isPROVIDER_FAILED; the rest (timeout, lost connection) isOUTCOME_UNKNOWN. Both keep the Redis error as theircause(D-0106), so a transient one answers @dokebi/redis's retryable 503 through dokebi-web. At start, a Redis whose policy evicts keys logs errorREDIS_EVICTION_UNSAFE(Redis for locks must benoeviction). An acquire reply that is neither a positive decimal string (fence), an integer of at least -2 (PTTL) nor nil (busy) isOUTCOME_UNKNOWN. OUTCOME_UNKNOWNmay mean the change already happened, so it is never retried automatically.
A key is a non-empty, well-formed UTF-16 string, by default at most 1024 UTF-8 bytes. Stored keys are separated by namespace, feature kind and SHA-256. Tokens, original values and addresses never appear in error messages; a failure's cause is the RedisError, which names none of them.
The runnable example is example 15. Operations checks such as Redis HA and the fault matrix are tracked in OPS-002.
