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.
244 lines (214 loc) • 7.53 kB
JavaScript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
/**
* 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 const ACQUIRE_SCRIPT = `
local lockKey = KEYS[1]
local lockIdKey = KEYS[2]
local fenceKey = KEYS[3]
local lockId = ARGV[1]
local ttlMs = tonumber(ARGV[2])
local toleranceMs = tonumber(ARGV[3])
local storageKey = ARGV[4]
local userKey = ARGV[5]
-- Redis TIME() converted to milliseconds for canonical time authority
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
local existingData = redis.call('GET', lockKey)
if existingData then
local data = cjson.decode(existingData)
if data.expiresAtMs > (nowMs - toleranceMs) then -- isLive() predicate
return 0 -- Contention
end
-- Expired lock index cleaned up by TTL (both keys share identical TTL)
end
-- INCR guarantees monotonic fencing, 15-digit format ensures Lua precision safety (2^53-1)
local fence = string.format("%015d", redis.call('INCR', fenceKey))
-- Atomic dual-key write with identical TTL
local expiresAtMs = nowMs + ttlMs
local lockData = cjson.encode({lockId=lockId, expiresAtMs=expiresAtMs, acquiredAtMs=nowMs, key=userKey, fence=fence})
redis.call('SET', lockKey, lockData, 'PX', ttlMs)
-- Store full lockKey in index (handles truncation, ADR-013)
redis.call('SET', lockIdKey, storageKey, 'PX', ttlMs)
return {1, fence, expiresAtMs}
`;
/**
* 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 const RELEASE_SCRIPT = `
local lockIdKey = KEYS[1]
local lockId = ARGV[1]
local toleranceMs = tonumber(ARGV[2])
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
-- Reverse lookup returns full lockKey (post-truncation, ADR-013)
local lockKey = redis.call('GET', lockIdKey)
if not lockKey then return -1 end
local lockData = redis.call('GET', lockKey)
if not lockData then return -1 end
local data = cjson.decode(lockData)
-- Expired lock cleanup (inverted isLive predicate)
if data.expiresAtMs <= (nowMs - toleranceMs) then
redis.call('DEL', lockKey, lockIdKey)
return -2
end
-- Ownership verification (defense-in-depth, ADR-003)
if data.lockId ~= lockId then return 0 end
-- Atomic dual-key delete
redis.call('DEL', lockKey, lockIdKey)
return 1
`;
/**
* 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 const EXTEND_SCRIPT = `
local lockIdKey = KEYS[1]
local lockId = ARGV[1]
local toleranceMs = tonumber(ARGV[2])
local ttlMs = tonumber(ARGV[3])
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
-- Reverse lookup returns full lockKey (post-truncation, ADR-013)
local lockKey = redis.call('GET', lockIdKey)
if not lockKey then return 0 end
local lockData = redis.call('GET', lockKey)
if not lockData then return 0 end
local data = cjson.decode(lockData)
-- Expired lock cleanup (all failures → 0 for simplicity)
if data.expiresAtMs <= (nowMs - toleranceMs) then
redis.call('DEL', lockKey, lockIdKey)
return 0
end
-- Ownership verification (ADR-003)
if data.lockId ~= lockId then return 0 end
-- Replace TTL entirely (not additive)
local newExpiresAtMs = nowMs + ttlMs
data.expiresAtMs = newExpiresAtMs
redis.call('SET', lockKey, cjson.encode(data), 'PX', ttlMs)
redis.call('SET', lockIdKey, lockKey, 'PX', ttlMs)
return {1, newExpiresAtMs}
`;
/**
* 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 const IS_LOCKED_SCRIPT = `
local lockKey = KEYS[1]
local keyPrefix = ARGV[1]
local toleranceMs = tonumber(ARGV[2])
local enableCleanup = ARGV[3] == "true"
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
local lockData = redis.call('GET', lockKey)
if not lockData then
return 0
end
local data = cjson.decode(lockData)
if data.expiresAtMs <= (nowMs - toleranceMs) then
-- Optional cleanup with 2s guard to prevent extend race conditions
if enableCleanup then
local guardMs = 2000
if nowMs - data.expiresAtMs > guardMs then
redis.call('DEL', lockKey)
if data.lockId then
local lockIdKey
if string.sub(keyPrefix, -1) == ":" then
lockIdKey = keyPrefix .. 'id:' .. data.lockId
else
lockIdKey = keyPrefix .. ':id:' .. data.lockId
end
redis.call('DEL', lockIdKey)
end
end
end
return 0
end
return 1
`;
/**
* 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 const LOOKUP_BY_KEY_SCRIPT = `
local lockKey = KEYS[1]
local toleranceMs = tonumber(ARGV[1])
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
local lockData = redis.call('GET', lockKey)
if not lockData then
return nil
end
local data = cjson.decode(lockData)
if data.expiresAtMs <= (nowMs - toleranceMs) then
return nil
end
return cjson.encode(data)
`;
/**
* 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 const LOOKUP_BY_LOCKID_SCRIPT = `
local lockIdKey = KEYS[1]
local lockId = ARGV[1]
local toleranceMs = tonumber(ARGV[2])
local time = redis.call('TIME')
local nowMs = time[1] * 1000 + math.floor(time[2] / 1000)
-- Reverse lookup returns full lockKey (post-truncation, ADR-013)
local lockKey = redis.call('GET', lockIdKey)
if not lockKey then return nil end
local lockData = redis.call('GET', lockKey)
if not lockData then return nil end
local data = cjson.decode(lockData)
if data.lockId ~= lockId then return nil end
if data.expiresAtMs <= (nowMs - toleranceMs) then return nil end
return lockData
`;