/** * 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";