UNPKG

syncguard

Version:

Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.

75 lines (74 loc) 7.67 kB
/** * Atomic lock acquisition with fencing tokens. * Flow: check expiration → generate fence token → set both keys with identical TTL * * @returns {1, fence, expiresAtMs} on success, 0 on contention * @see docs/specs/redis-backend.md * * KEYS: [lockKey, lockIdKey, fenceKey] * ARGV: [lockId, ttlMs, toleranceMs, storageKey, userKey] * * NOTE: storageKey = full computed lockKey (post-truncation) stored in index for retrieval. * userKey = original normalized key stored in lockData for lookup operations (ADR-013). */ export declare const ACQUIRE_SCRIPT = "\nlocal lockKey = KEYS[1]\nlocal lockIdKey = KEYS[2]\nlocal fenceKey = KEYS[3]\nlocal lockId = ARGV[1]\nlocal ttlMs = tonumber(ARGV[2])\nlocal toleranceMs = tonumber(ARGV[3])\nlocal storageKey = ARGV[4]\nlocal userKey = ARGV[5]\n\n-- Redis TIME() converted to milliseconds for canonical time authority\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\nlocal existingData = redis.call('GET', lockKey)\nif existingData then\n local data = cjson.decode(existingData)\n if data.expiresAtMs > (nowMs - toleranceMs) then -- isLive() predicate\n return 0 -- Contention\n end\n -- Expired lock index cleaned up by TTL (both keys share identical TTL)\nend\n-- INCR guarantees monotonic fencing, 15-digit format ensures Lua precision safety (2^53-1)\nlocal fence = string.format(\"%015d\", redis.call('INCR', fenceKey))\n-- Atomic dual-key write with identical TTL\nlocal expiresAtMs = nowMs + ttlMs\nlocal lockData = cjson.encode({lockId=lockId, expiresAtMs=expiresAtMs, acquiredAtMs=nowMs, key=userKey, fence=fence})\nredis.call('SET', lockKey, lockData, 'PX', ttlMs)\n-- Store full lockKey in index (handles truncation, ADR-013)\nredis.call('SET', lockIdKey, storageKey, 'PX', ttlMs)\nreturn {1, fence, expiresAtMs}\n"; /** * Atomic lock release with ownership verification. * Flow: reverse lookup → verify ownership → atomic delete * * @returns 1=success, 0=ownership mismatch, -1=not found, -2=expired * @see docs/adr/003-explicit-ownership-verification.md - Ownership verification * @see docs/adr/013-full-storage-key-in-index.md - Index retrieval * * KEYS: [lockIdKey] * ARGV: [lockId, toleranceMs] */ export declare const RELEASE_SCRIPT = "\nlocal lockIdKey = KEYS[1]\nlocal lockId = ARGV[1]\nlocal toleranceMs = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\n\n-- Reverse lookup returns full lockKey (post-truncation, ADR-013)\nlocal lockKey = redis.call('GET', lockIdKey)\nif not lockKey then return -1 end\n\nlocal lockData = redis.call('GET', lockKey)\nif not lockData then return -1 end\n\nlocal data = cjson.decode(lockData)\n\n-- Expired lock cleanup (inverted isLive predicate)\nif data.expiresAtMs <= (nowMs - toleranceMs) then\n redis.call('DEL', lockKey, lockIdKey)\n return -2\nend\n\n-- Ownership verification (defense-in-depth, ADR-003)\nif data.lockId ~= lockId then return 0 end\n\n-- Atomic dual-key delete\nredis.call('DEL', lockKey, lockIdKey)\nreturn 1\n"; /** * Atomic lock extension with ownership verification. * Flow: reverse lookup → verify ownership → replace TTL entirely (not additive) * * @returns {1, newExpiresAtMs} on success, 0 on ownership mismatch/not found/expired * @see docs/adr/003-explicit-ownership-verification.md - Ownership verification * @see docs/adr/013-full-storage-key-in-index.md - Index retrieval * * KEYS: [lockIdKey] * ARGV: [lockId, toleranceMs, ttlMs] */ export declare const EXTEND_SCRIPT = "\nlocal lockIdKey = KEYS[1]\nlocal lockId = ARGV[1]\nlocal toleranceMs = tonumber(ARGV[2])\nlocal ttlMs = tonumber(ARGV[3])\n\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\n\n-- Reverse lookup returns full lockKey (post-truncation, ADR-013)\nlocal lockKey = redis.call('GET', lockIdKey)\nif not lockKey then return 0 end\n\nlocal lockData = redis.call('GET', lockKey)\nif not lockData then return 0 end\n\nlocal data = cjson.decode(lockData)\n\n-- Expired lock cleanup (all failures \u2192 0 for simplicity)\nif data.expiresAtMs <= (nowMs - toleranceMs) then\n redis.call('DEL', lockKey, lockIdKey)\n return 0\nend\n\n-- Ownership verification (ADR-003)\nif data.lockId ~= lockId then return 0 end\n\n-- Replace TTL entirely (not additive)\nlocal newExpiresAtMs = nowMs + ttlMs\ndata.expiresAtMs = newExpiresAtMs\nredis.call('SET', lockKey, cjson.encode(data), 'PX', ttlMs)\nredis.call('SET', lockIdKey, lockKey, 'PX', ttlMs)\nreturn {1, newExpiresAtMs}\n"; /** * Lock status check with optional expired lock cleanup. * Cleanup uses 2s safety buffer to prevent extend() race conditions. * * @returns 1 if locked and live, 0 otherwise * @see docs/specs/redis-backend.md * * KEYS: [lockKey] * ARGV: [keyPrefix, toleranceMs, enableCleanup ("true"|"false")] */ export declare const IS_LOCKED_SCRIPT = "\nlocal lockKey = KEYS[1]\nlocal keyPrefix = ARGV[1]\nlocal toleranceMs = tonumber(ARGV[2])\nlocal enableCleanup = ARGV[3] == \"true\"\n\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\n\nlocal lockData = redis.call('GET', lockKey)\nif not lockData then\n return 0\nend\n\nlocal data = cjson.decode(lockData)\nif data.expiresAtMs <= (nowMs - toleranceMs) then\n -- Optional cleanup with 2s guard to prevent extend race conditions\n if enableCleanup then\n local guardMs = 2000\n if nowMs - data.expiresAtMs > guardMs then\n redis.call('DEL', lockKey)\n if data.lockId then\n local lockIdKey\n if string.sub(keyPrefix, -1) == \":\" then\n lockIdKey = keyPrefix .. 'id:' .. data.lockId\n else\n lockIdKey = keyPrefix .. ':id:' .. data.lockId\n end\n redis.call('DEL', lockIdKey)\n end\n end\n end\n return 0\nend\n\nreturn 1\n"; /** * Lookup lock by key, returns info only if live. * * @returns lock info (JSON) if live, nil otherwise * @see docs/specs/interface.md * * KEYS: [lockKey] * ARGV: [toleranceMs] */ export declare const LOOKUP_BY_KEY_SCRIPT = "\nlocal lockKey = KEYS[1]\nlocal toleranceMs = tonumber(ARGV[1])\n\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\n\nlocal lockData = redis.call('GET', lockKey)\nif not lockData then\n return nil\nend\n\nlocal data = cjson.decode(lockData)\nif data.expiresAtMs <= (nowMs - toleranceMs) then\n return nil\nend\n\nreturn cjson.encode(data)\n"; /** * Lookup lock by lockId using atomic reverse mapping. * Verifies ownership before returning lock info. * * NOTE: Atomicity prevents TOCTOU races during multi-key reads (ADR-011: required for * Redis multi-key pattern, optional for Firestore indexed queries). Lookup is DIAGNOSTIC * ONLY—correctness relies on atomic release/extend operations, NOT lookup results. * * @returns lock info (JSON) if live and owned, nil otherwise * @see docs/specs/interface.md * * KEYS: [lockIdKey] * ARGV: [lockId, toleranceMs] */ export declare const LOOKUP_BY_LOCKID_SCRIPT = "\nlocal lockIdKey = KEYS[1]\nlocal lockId = ARGV[1]\nlocal toleranceMs = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal nowMs = time[1] * 1000 + math.floor(time[2] / 1000)\n\n-- Reverse lookup returns full lockKey (post-truncation, ADR-013)\nlocal lockKey = redis.call('GET', lockIdKey)\nif not lockKey then return nil end\n\nlocal lockData = redis.call('GET', lockKey)\nif not lockData then return nil end\n\nlocal data = cjson.decode(lockData)\nif data.lockId ~= lockId then return nil end\n\nif data.expiresAtMs <= (nowMs - toleranceMs) then return nil end\n\nreturn lockData\n";