UNPKG

redlock-universal

Version:

Production-ready distributed Redis locks for Node.js with support for both node-redis and ioredis clients

1,596 lines (1,579 loc) 78.8 kB
'use strict'; var crypto = require('crypto'); /** * redlock-universal * Production-ready distributed Redis locks for Node.js */ // src/types/errors.ts var RedlockError = class extends Error { constructor(message, cause) { super(message); this.name = this.constructor.name; if (cause) { this.cause = cause; } } }; var LockAcquisitionError = class extends RedlockError { constructor(key, attempts, cause) { super( `Failed to acquire lock "${key}" after ${attempts} attempts${cause ? `: ${cause.message}` : ""}`, cause ); this.key = key; this.attempts = attempts; } code = "LOCK_ACQUISITION_FAILED"; }; var LockReleaseError = class extends RedlockError { constructor(key, reason, cause) { super(`Failed to release lock "${key}": ${reason}${cause ? `: ${cause.message}` : ""}`, cause); this.key = key; this.reason = reason; } code = "LOCK_RELEASE_FAILED"; }; var LockExtensionError = class extends RedlockError { constructor(key, reason, cause) { super(`Failed to extend lock "${key}": ${reason}${cause ? `: ${cause.message}` : ""}`, cause); this.key = key; this.reason = reason; } code = "LOCK_EXTENSION_FAILED"; }; var AdapterError = class extends RedlockError { code = "ADAPTER_ERROR"; constructor(message, cause) { super(`Redis adapter error: ${message}`, cause); } }; var ConfigurationError = class extends RedlockError { code = "CONFIGURATION_ERROR"; constructor(message) { super(`Configuration error: ${message}`); } }; var LOCK_VALUE_BUFFER = Buffer.allocUnsafe(16); var LOCK_ID_BUFFER = Buffer.allocUnsafe(6); function generateLockValue() { crypto.randomFillSync(LOCK_VALUE_BUFFER); return LOCK_VALUE_BUFFER.toString("hex"); } function generateLockId() { const timestamp = Date.now(); crypto.randomFillSync(LOCK_ID_BUFFER); const random = LOCK_ID_BUFFER.toString("hex"); return `${timestamp}-${random}`; } var COMPARE_BUFFER_A = Buffer.allocUnsafe(256); var COMPARE_BUFFER_B = Buffer.allocUnsafe(256); function safeCompare(a, b) { if (a.length !== b.length) { return false; } const len = a.length; if (len > 256) { return false; } COMPARE_BUFFER_A.write(a, 0, len, "utf8"); COMPARE_BUFFER_B.write(b, 0, len, "utf8"); return crypto.timingSafeEqual(COMPARE_BUFFER_A.subarray(0, len), COMPARE_BUFFER_B.subarray(0, len)); } var METADATA_LOCK_VALUE_BUFFER = Buffer.allocUnsafe(8); function createLockValueWithMetadata(nodeId) { const timestamp = Date.now(); crypto.randomFillSync(METADATA_LOCK_VALUE_BUFFER); const random = METADATA_LOCK_VALUE_BUFFER.toString("hex"); const node = nodeId || "node"; return `${node}:${timestamp}:${random}`; } function parseLockValue(value) { const parts = value.split(":"); if (parts.length !== 3) { return null; } const [nodeId, timestampStr, random] = parts; if (!nodeId || !timestampStr || !random) { return null; } if (!/^\d+$/.test(timestampStr)) { return null; } const timestamp = parseInt(timestampStr, 10); if (isNaN(timestamp)) { return null; } return { nodeId, timestamp, random }; } function isValidLockValue(value) { if (!value || typeof value !== "string") { return false; } if (value.length < 8 || value.length > 256) { return false; } if (value.includes("\n") || value.includes("\r") || value.includes("\0")) { return false; } return true; } // src/adapters/BaseAdapter.ts var MAX_KEY_LENGTH = 512; var MAX_VALUE_LENGTH = 1024; var MAX_TTL_MS = 864e5; var ATOMIC_EXTEND_SCRIPT = ` local current_ttl = redis.call("PTTL", KEYS[1]) local min_ttl = tonumber(ARGV[2]) local new_ttl = tonumber(ARGV[3]) -- Check if key exists if current_ttl == -2 then return {-1, -2} -- Key doesn't exist end -- Check if we have enough time remaining for safe extension if current_ttl < min_ttl then return {0, current_ttl} -- Too late, include actual TTL for logging end -- Check value and extend atomically local current_value = redis.call("GET", KEYS[1]) if current_value == ARGV[1] then redis.call("PEXPIRE", KEYS[1], new_ttl) return {1, current_ttl} -- Success with original TTL else return {-1, current_ttl} -- Value mismatch (lock stolen) end `.trim(); var DELETE_IF_MATCH_SCRIPT = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `.trim(); var EXTEND_IF_MATCH_SCRIPT = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end `.trim(); var BATCH_ACQUIRE_SCRIPT = ` -- Phase 1: Check all keys are available for i = 1, #KEYS do if redis.call("EXISTS", KEYS[i]) == 1 then return {0, i, KEYS[i]} end end -- Phase 2: All keys available, acquire atomically local ttl = tonumber(ARGV[#ARGV]) for i = 1, #KEYS do redis.call("SET", KEYS[i], ARGV[i], "PX", ttl) end return {1, #KEYS} `.trim(); var BaseAdapter = class { options; scriptSHAs = /* @__PURE__ */ new Map(); constructor(options = {}) { const baseOptions = { keyPrefix: options.keyPrefix ?? "", maxRetries: options.maxRetries ?? 3, retryDelay: options.retryDelay ?? DEFAULTS.RETRY_DELAY, timeout: options.timeout ?? DEFAULTS.REDIS_TIMEOUT }; this.options = options.logger ? { ...baseOptions, logger: options.logger } : baseOptions; } /** * Validates lock key format and requirements */ validateKey(key) { if (!key || typeof key !== "string") { throw new TypeError("Lock key must be a non-empty string"); } if (key.length > MAX_KEY_LENGTH) { throw new TypeError(`Lock key must be less than ${MAX_KEY_LENGTH} characters`); } if (key.includes("\n") || key.includes("\r")) { throw new TypeError("Lock key cannot contain newline characters"); } } /** * Validates lock value format and requirements */ validateValue(value) { if (!value || typeof value !== "string") { throw new TypeError("Lock value must be a non-empty string"); } if (value.length > MAX_VALUE_LENGTH) { throw new TypeError(`Lock value must be less than ${MAX_VALUE_LENGTH} characters`); } } /** * Validates TTL (time-to-live) value */ validateTTL(ttl) { if (!Number.isInteger(ttl) || ttl <= 0) { throw new TypeError("TTL must be a positive integer"); } if (ttl > MAX_TTL_MS) { throw new TypeError(`TTL cannot exceed 24 hours (${MAX_TTL_MS}ms)`); } } /** * Adds prefix to key if configured */ prefixKey(key) { return this.options.keyPrefix ? `${this.options.keyPrefix}${key}` : key; } stripPrefix(prefixedKey) { if (this.options.keyPrefix && prefixedKey.startsWith(this.options.keyPrefix)) { return prefixedKey.slice(this.options.keyPrefix.length); } return prefixedKey; } /** * Handles timeout for Redis operations * Properly cleans up timeout handles to prevent memory leaks */ async withTimeout(operation, timeoutMs = this.options.timeout) { let timeoutHandle = null; const timeoutPromise = new Promise((_, reject) => { timeoutHandle = setTimeout( () => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs ); }); try { return await Promise.race([operation, timeoutPromise]); } finally { if (timeoutHandle !== null) { clearTimeout(timeoutHandle); } } } /** * Interpret atomic extension script result into structured response */ interpretAtomicExtensionResult(key, minTTL, scriptResult) { const [resultCode, actualTTL] = scriptResult; switch (resultCode) { case 1: return { resultCode: 1, actualTTL, message: `Extension successful (${actualTTL}ms remaining before extension)` }; case 0: return { resultCode: 0, actualTTL, message: `Extension too late (only ${actualTTL}ms left, needed ${minTTL}ms minimum)` }; case -1: return { resultCode: -1, actualTTL, message: actualTTL === -2 ? `Lock key "${key}" no longer exists` : `Lock value changed - lock stolen (${actualTTL}ms remaining)` }; default: return { resultCode: -1, actualTTL, message: `Unexpected result code: ${resultCode}` }; } } }; // src/constants.ts var DEFAULTS = { /** Default lock TTL in milliseconds (30 seconds) */ TTL: 3e4, /** Default retry attempts */ RETRY_ATTEMPTS: 3, /** Default retry delay in milliseconds */ RETRY_DELAY: 100, /** Default Redis command timeout in milliseconds */ REDIS_TIMEOUT: 5e3, /** Default clock drift factor for Redlock */ CLOCK_DRIFT_FACTOR: 0.01, /** Default monitoring interval in milliseconds (1 minute) */ MONITORING_INTERVAL: 6e4, /** Default health check interval in milliseconds (30 seconds) */ HEALTH_CHECK_INTERVAL: 3e4, /** Circuit breaker failure threshold */ CIRCUIT_BREAKER_THRESHOLD: 5, /** Circuit breaker timeout in milliseconds (1 minute) */ CIRCUIT_BREAKER_TIMEOUT: 6e4, /** Auto-extension threshold ratio (extend when 80% of TTL consumed) */ AUTO_EXTENSION_THRESHOLD_RATIO: 0.2, /** Minimum extension interval in milliseconds */ MIN_EXTENSION_INTERVAL: 1e3, /** Safety buffer for atomic extension (minimum TTL required) */ ATOMIC_EXTENSION_SAFETY_BUFFER: 2e3, /** * Extension buffer ratio for single-node locks (10%) * * This ratio determines how much TTL must remain before atomic extension. * For single-node locks, we use a larger buffer (10%) because: * - Lower coordination overhead allows larger safety margin * - Single point of failure means we can be more conservative * - Network latency to one node is more predictable * * Example: For a 30-second TTL, extension triggers with 3 seconds remaining */ SINGLE_NODE_EXTENSION_BUFFER_RATIO: 0.1, /** * Extension buffer ratio for distributed locks (5%) * * This ratio determines how much TTL must remain before atomic extension. * For distributed locks (RedLock), we use a smaller buffer (5%) because: * - Multiple nodes require more coordination time * - Smaller ratio ensures we extend before ANY node expires * - Clock drift across nodes necessitates earlier extension * - Quorum-based approach means we need tighter timing * * Example: For a 30-second TTL, extension triggers with 1.5 seconds remaining */ DISTRIBUTED_EXTENSION_BUFFER_RATIO: 0.05 }; var LUA_SCRIPTS = { /** Script to safely release a lock (check value before delete) */ RELEASE: DELETE_IF_MATCH_SCRIPT, /** Script to safely extend a lock (check value before extend) */ EXTEND: EXTEND_IF_MATCH_SCRIPT }; var ERROR_MESSAGES = { UNKNOWN_ERROR: "Unknown error" }; var LIBRARY_INFO = { NAME: "redlock-universal", VERSION: "0.1.0", DESCRIPTION: "Production-ready distributed Redis locks for Node.js" }; // src/utils/auto-extension.ts async function executeWithAutoExtension(config) { const { locks, handles, ttl, routine, extensionThresholdRatio = DEFAULTS.AUTO_EXTENSION_THRESHOLD_RATIO, minExtensionInterval = DEFAULTS.MIN_EXTENSION_INTERVAL, logger: logger2 } = config; if (locks.length !== handles.length) { throw new Error("Number of locks and handles must match"); } if (locks.length === 0) { throw new Error("At least one lock must be provided"); } let extensionTimer = null; let extending; let isAborted = false; let abortError; let lastExtensionTime = Math.max(...handles.map((h) => h.acquiredAt)); const threshold = Math.floor(ttl * extensionThresholdRatio); const abortController = new AbortController(); const enhancedSignal = abortController.signal; Object.defineProperty(enhancedSignal, "error", { get: () => abortError, enumerable: true, configurable: false }); const scheduleExtension = () => { if (extensionTimer) { clearTimeout(extensionTimer); } const now = Date.now(); const timeUntilExpiry = lastExtensionTime + ttl - now; const timeUntilExtension = timeUntilExpiry - threshold; const safeExtensionTime = Math.max(timeUntilExtension, minExtensionInterval); if (safeExtensionTime <= minExtensionInterval) { void attemptExtension(); } else { extensionTimer = setTimeout(attemptExtension, safeExtensionTime); } }; const attemptExtension = async () => { if (isAborted) return; const extensionPromises = locks.map(async (lock, index) => { try { const handle = handles[index]; const adapter = lock.getAdapter?.(); if (adapter && adapter.atomicExtend) { const isDistributed = locks.length > 1; const bufferRatio = isDistributed ? DEFAULTS.DISTRIBUTED_EXTENSION_BUFFER_RATIO : DEFAULTS.SINGLE_NODE_EXTENSION_BUFFER_RATIO; const proportionalSafetyBuffer = Math.min( DEFAULTS.ATOMIC_EXTENSION_SAFETY_BUFFER, Math.floor(ttl * bufferRatio) ); const atomicResult = await adapter.atomicExtend( handle.key, handle.value, proportionalSafetyBuffer, ttl ); if (logger2) { const level = atomicResult.resultCode === 1 ? "info" : "warn"; logger2[level]("Atomic extension attempt", { key: handle.key, resultCode: atomicResult.resultCode, actualTTL: atomicResult.actualTTL, message: atomicResult.message }); } return { success: atomicResult.resultCode === 1, error: atomicResult.resultCode === 1 ? null : new Error(atomicResult.message), atomicResult }; } const result = await lock.extend(handle, ttl); return { success: result, error: null, atomicResult: null }; } catch (error) { return { success: false, error: error instanceof Error ? error : new Error("Extension failed"), atomicResult: null }; } }); extending = Promise.all(extensionPromises).then((results) => { const successes = results.map((r) => r.success); const allSuccess = successes.every(Boolean); let failedLocks; if (allSuccess) { lastExtensionTime = Date.now(); const atomicResults = results.map((r) => r.atomicResult).filter((result) => result !== null); if (atomicResults.length > 0 && logger2) { logger2.info("All locks extended successfully with atomic protection", { lockCount: results.length, atomicCount: atomicResults.length, avgRemainingTTL: Math.round( atomicResults.reduce((sum, r) => sum + r.actualTTL, 0) / atomicResults.length ) }); } scheduleExtension(); } else { failedLocks = handles.map((handle, i) => successes[i] ? null : handle.key).filter(Boolean); const atomicFailures = results.filter((r) => !r.success && r.atomicResult).map((r) => `${r.atomicResult.message} (TTL: ${r.atomicResult.actualTTL}ms)`); const errorMessage = atomicFailures.length > 0 ? `Failed to extend locks with atomic protection: ${atomicFailures.join("; ")}` : `Failed to extend ${failedLocks.length === 1 ? "lock" : "locks"}: ${failedLocks.join(", ")}`; abortError = new Error(errorMessage); isAborted = true; abortController.abort(); } return { success: allSuccess, failedKeys: failedLocks, error: allSuccess ? void 0 : abortError }; }); }; try { scheduleExtension(); return await routine(enhancedSignal); } finally { if (extensionTimer) { clearTimeout(extensionTimer); extensionTimer = null; } if (extending) { await extending.catch(() => { }); } const releasePromises = locks.map(async (lock, index) => { try { await lock.release(handles[index]); } catch (error) { const releaseError = error instanceof Error ? error : new Error("Unknown error"); if (logger2) { logger2.error("Failed to release lock in using()", releaseError, { key: handles[index].key, lockIndex: index }); } } }); await Promise.allSettled(releasePromises); } } async function executeWithSingleLockExtension(lock, handle, ttl, routine, logger2) { const baseConfig = { locks: [lock], handles: [handle], ttl, routine }; return executeWithAutoExtension(logger2 ? { ...baseConfig, logger: logger2 } : baseConfig); } // src/locks/SimpleLock.ts var REDIS_OK_RESPONSE = "OK"; var SimpleLock = class { adapter; key; ttl; retryAttempts; retryDelay; logger; correlationId; onAcquire; onRelease; _configCache; _lastHealthCheck = 0; _healthCheckInterval = DEFAULTS.HEALTH_CHECK_INTERVAL; _isHealthy = true; _circuitBreakerFailures = 0; _circuitBreakerThreshold = DEFAULTS.CIRCUIT_BREAKER_THRESHOLD; _circuitBreakerTimeout = DEFAULTS.CIRCUIT_BREAKER_TIMEOUT; _circuitBreakerOpenedAt = 0; _circuitBreakerState = "closed"; _metadataTemplate; constructor(config) { this.validateConfig(config); this.adapter = config.adapter; this.key = config.key; this.ttl = config.ttl ?? DEFAULTS.TTL; this.retryAttempts = config.retryAttempts ?? DEFAULTS.RETRY_ATTEMPTS; this.retryDelay = config.retryDelay ?? DEFAULTS.RETRY_DELAY; this.logger = config.logger; this.correlationId = config.correlationId; this.onAcquire = config.onAcquire; this.onRelease = config.onRelease; this._metadataTemplate = Object.freeze({ strategy: "simple", ...this.correlationId && { correlationId: this.correlationId } }); } /** * Validate configuration parameters */ validateConfig(config) { if (!config.key || typeof config.key !== "string") { throw new Error("Lock key must be a non-empty string"); } const ttl = config.ttl ?? DEFAULTS.TTL; if (ttl <= 0 || !Number.isInteger(ttl)) { throw new Error("TTL must be a positive integer"); } const retryAttempts = config.retryAttempts ?? DEFAULTS.RETRY_ATTEMPTS; if (retryAttempts < 0 || !Number.isInteger(retryAttempts)) { throw new Error("Retry attempts must be a non-negative integer"); } const retryDelay = config.retryDelay ?? DEFAULTS.RETRY_DELAY; if (retryDelay < 0 || !Number.isInteger(retryDelay)) { throw new Error("Retry delay must be a non-negative integer"); } } /** * Circuit breaker pattern implementation */ updateCircuitBreaker(isSuccess) { const now = Date.now(); if (isSuccess) { this._circuitBreakerFailures = 0; if (this._circuitBreakerState === "half-open") { this._circuitBreakerState = "closed"; if (this.logger) { this.logger.info("Circuit breaker closed - Redis recovered", { key: this.key, correlationId: this.correlationId, circuitBreakerState: this._circuitBreakerState }); } } } else { this._circuitBreakerFailures++; if (this._circuitBreakerState === "closed" && this._circuitBreakerFailures >= this._circuitBreakerThreshold) { this._circuitBreakerState = "open"; this._circuitBreakerOpenedAt = now; if (this.logger) { this.logger.error("Circuit breaker opened - Redis failing", void 0, { key: this.key, correlationId: this.correlationId, failures: this._circuitBreakerFailures, circuitBreakerState: this._circuitBreakerState }); } } } if (this._circuitBreakerState === "open" && now - this._circuitBreakerOpenedAt > this._circuitBreakerTimeout) { this._circuitBreakerState = "half-open"; if (this.logger) { this.logger.info("Circuit breaker half-open - testing Redis", { key: this.key, correlationId: this.correlationId, circuitBreakerState: this._circuitBreakerState }); } } } /** * Check Redis connection health periodically */ async checkConnectionHealth() { const now = Date.now(); if (now - this._lastHealthCheck < this._healthCheckInterval) { return; } this._lastHealthCheck = now; try { await this.adapter.ping(); this.updateCircuitBreaker(true); if (!this._isHealthy) { this._isHealthy = true; if (this.logger) { this.logger.info("Redis connection recovered", { key: this.key, correlationId: this.correlationId, healthStatus: "recovered" }); } } } catch (error) { this._isHealthy = false; this.updateCircuitBreaker(false); if (this.logger) { this.logger.error("Redis health check failed", error, { key: this.key, correlationId: this.correlationId, healthStatus: "failed" }); } } } /** * Attempt to acquire the lock */ async acquire() { if (this._circuitBreakerState === "open") { throw new LockAcquisitionError( this.key, 0, new Error("Circuit breaker is open - Redis is failing") ); } await this.checkConnectionHealth(); const startTime = Date.now(); let lastError = null; const lockValue = generateLockValue(); for (let attempt = 0; attempt <= this.retryAttempts; attempt++) { try { const result = await this.adapter.setNX(this.key, lockValue, this.ttl); this.updateCircuitBreaker(true); if (result === REDIS_OK_RESPONSE) { const acquisitionTime = Date.now() - startTime; const acquiredAt = Date.now(); const handle = { id: generateLockId(), key: this.key, value: lockValue, acquiredAt, ttl: this.ttl, metadata: { attempts: attempt + 1, acquisitionTime, ...this._metadataTemplate } }; this.onAcquire?.(handle); return handle; } if (!lastError) { lastError = new Error(`Lock "${this.key}" is already held`); } } catch (error) { lastError = error; this.updateCircuitBreaker(false); if (error instanceof Error && error.message?.includes("ECONNREFUSED")) { if (this.logger) { this.logger.error("Redis connection failed for lock", error, { key: this.key, correlationId: this.correlationId, attempt: attempt + 1, circuitBreaker: this._circuitBreakerState }); } } } if (attempt < this.retryAttempts) { await this.sleep(this.retryDelay); } } throw new LockAcquisitionError( this.key, this.retryAttempts + 1, lastError || new Error(ERROR_MESSAGES.UNKNOWN_ERROR) ); } /** * Release a previously acquired lock */ async release(handle) { this.validateHandle(handle); try { const released = await this.adapter.delIfMatch(handle.key, handle.value); this.onRelease?.(handle); return released; } catch (error) { throw new LockReleaseError(handle.key, "redis_error", error); } } /** * Extend the TTL of an existing lock */ async extend(handle, ttl) { this.validateHandle(handle); if (ttl <= 0 || !Number.isInteger(ttl)) { throw new Error("TTL must be a positive integer"); } try { return await this.adapter.extendIfMatch(handle.key, handle.value, ttl); } catch (error) { throw new LockExtensionError(handle.key, "redis_error", error); } } /** * Check if a lock is currently held */ async isLocked(key) { try { const value = await this.adapter.get(key); return value !== null; } catch (error) { return false; } } /** * Validate lock handle */ validateHandle(handle) { if (!handle) { throw new Error("Lock handle is required"); } if (!handle.id || !handle.key || !handle.value) { throw new Error("Invalid lock handle: missing required properties"); } if (handle.key !== this.key) { throw new Error(`Lock handle key "${handle.key}" does not match lock key "${this.key}"`); } } /** * Sleep for specified milliseconds */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get lock configuration (for debugging) */ getConfig() { if (!this._configCache) { this._configCache = Object.freeze({ adapter: this.adapter, key: this.key, ttl: this.ttl, retryAttempts: this.retryAttempts, retryDelay: this.retryDelay }); } return this._configCache; } /** * Get the underlying Redis adapter (for advanced usage) */ getAdapter() { return this.adapter; } /** * Get connection health status */ getHealth() { return { healthy: this._isHealthy, lastCheck: this._lastHealthCheck, connected: this.adapter.isConnected(), circuitBreaker: { state: this._circuitBreakerState, failures: this._circuitBreakerFailures, openedAt: this._circuitBreakerOpenedAt } }; } /** * Execute a routine with automatic lock management and extension * Auto-extends when remaining TTL < 20% (extends at ~80% consumed) * Provides AbortSignal when extension fails * * @param routine - Function to execute while holding the lock * @returns Result of the routine */ async using(routine) { const handle = await this.acquire(); if (this.logger) { return executeWithSingleLockExtension(this, handle, this.ttl, routine, this.logger); } else { return executeWithSingleLockExtension(this, handle, this.ttl, routine); } } }; // src/locks/LeanSimpleLock.ts var REDIS_OK_RESPONSE2 = "OK"; var LOCK_HELD_ERROR = new Error("Lock already held"); LOCK_HELD_ERROR.stack = ""; var LeanSimpleLock = class { a; k; t; r; d; constructor(config) { this.a = config.adapter; this.k = config.key; this.t = config.ttl ?? DEFAULTS.TTL; this.r = config.retryAttempts ?? DEFAULTS.RETRY_ATTEMPTS; this.d = config.retryDelay ?? DEFAULTS.RETRY_DELAY; } async acquire() { const startTime = Date.now(); let lastError = null; let attempts = 0; const value = `${startTime}-${Math.random().toString(36).slice(2)}-${process.pid}`; for (let attempt = 0; attempt <= this.r; attempt++) { attempts++; try { const result = await this.a.setNX(this.k, value, this.t); if (result === REDIS_OK_RESPONSE2) { const acquisitionTime = Date.now() - startTime; return { id: value, key: this.k, value, acquiredAt: startTime, ttl: this.t, metadata: { attempts, acquisitionTime, strategy: "simple" } }; } lastError = LOCK_HELD_ERROR; } catch (error) { lastError = error instanceof Error ? error : LOCK_HELD_ERROR; } if (attempt < this.r) { await new Promise((r) => setTimeout(r, this.d)); } } throw new LockAcquisitionError(this.k, this.r + 1, lastError); } async release(handle) { try { return await this.a.delIfMatch(handle.key, handle.value); } catch (error) { throw new LockReleaseError( handle.key, "redis_error", error instanceof Error ? error : void 0 ); } } async extend(handle, newTtl) { try { return await this.a.extendIfMatch(handle.key, handle.value, newTtl); } catch (error) { throw new LockExtensionError( handle.key, "redis_error", error instanceof Error ? error : void 0 ); } } async isLocked(key) { try { const value = await this.a.get(key); return value !== null; } catch { return false; } } /** * Get the underlying Redis adapter for atomic operations * @returns Redis adapter instance */ getAdapter() { return this.a; } /** * Execute a routine with automatic lock management and extension * Auto-extends when remaining TTL < 20% (extends at ~80% consumed) * Provides AbortSignal when extension fails * * @param routine - Function to execute while holding the lock * @returns Result of the routine */ async using(routine) { const handle = await this.acquire(); return executeWithSingleLockExtension(this, handle, this.t, routine); } }; // src/locks/RedLock.ts var REDIS_OK_RESPONSE3 = "OK"; var DISTRIBUTED_RETRY_MULTIPLIER = 2; var QUORUM_DIVISOR = 2; var QUORUM_OFFSET = 1; var RedLock = class { adapters; config; constructor(config) { this.adapters = config.adapters; const baseConfig = { adapters: config.adapters, key: config.key, ttl: config.ttl ?? DEFAULTS.TTL, quorum: config.quorum ?? Math.floor(config.adapters.length / QUORUM_DIVISOR) + QUORUM_OFFSET, retryAttempts: config.retryAttempts ?? DEFAULTS.RETRY_ATTEMPTS, retryDelay: config.retryDelay ?? DEFAULTS.RETRY_DELAY * DISTRIBUTED_RETRY_MULTIPLIER, clockDriftFactor: config.clockDriftFactor ?? DEFAULTS.CLOCK_DRIFT_FACTOR }; this.config = config.logger ? { ...baseConfig, logger: config.logger } : baseConfig; this.validateConfig(); } /** * Validate RedLock configuration */ validateConfig() { if (!this.config.adapters || this.config.adapters.length === 0) { throw new Error("At least one Redis adapter is required for RedLock"); } if (!this.config.key || typeof this.config.key !== "string") { throw new Error("Lock key must be a non-empty string"); } if (this.config.ttl <= 0 || !Number.isInteger(this.config.ttl)) { throw new Error("TTL must be a positive integer"); } if (this.config.quorum < 1 || this.config.quorum > this.config.adapters.length) { throw new Error( `Quorum must be between 1 and ${this.config.adapters.length} (number of adapters)` ); } if (this.config.retryAttempts < 0 || !Number.isInteger(this.config.retryAttempts)) { throw new Error("Retry attempts must be a non-negative integer"); } if (this.config.retryDelay < 0 || !Number.isInteger(this.config.retryDelay)) { throw new Error("Retry delay must be a non-negative integer"); } if (this.config.clockDriftFactor < 0 || this.config.clockDriftFactor >= 1) { throw new Error("Clock drift factor must be between 0 and 1"); } } /** * Attempt to acquire the distributed lock using Redlock algorithm */ async acquire() { const startTime = Date.now(); let lastError = null; for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) { try { const lockValue = generateLockValue(); const result = await this.attemptLockAcquisition(lockValue); if (result.success) { const acquisitionTime = Date.now() - startTime; return { id: generateLockId(), key: this.config.key, value: lockValue, acquiredAt: Date.now(), ttl: this.config.ttl, metadata: { attempts: attempt + 1, acquisitionTime, nodes: result.successfulNodes, strategy: "redlock" } }; } lastError = new Error( `RedLock quorum not achieved: ${result.successCount}/${this.config.quorum} required` ); await this.releasePartialLocks(result.nodeResults, lockValue); } catch (error) { lastError = error instanceof Error ? error : new Error(ERROR_MESSAGES.UNKNOWN_ERROR); } if (attempt < this.config.retryAttempts) { await this.sleep(this.config.retryDelay); } } throw new LockAcquisitionError( this.config.key, this.config.retryAttempts + 1, lastError || new Error(ERROR_MESSAGES.UNKNOWN_ERROR) ); } /** * Attempt to acquire lock on all Redis nodes */ async attemptLockAcquisition(lockValue) { const startTime = Date.now(); const lockPromises = this.adapters.map( (adapter, index) => this.acquireOnSingleNode(adapter, lockValue, `node-${index}`) ); const nodeResults = await Promise.allSettled(lockPromises); const actualResults = nodeResults.map((result, index) => { if (result.status === "fulfilled") { return result.value; } else { return { success: false, adapter: this.adapters[index], nodeId: `node-${index}`, error: result.reason, operationTime: Date.now() - startTime }; } }); const successfulResults = actualResults.filter((result) => result.success); const successCount = successfulResults.length; const hasQuorum = successCount >= this.config.quorum; const totalTime = Date.now() - startTime; const driftTime = Math.floor(this.config.ttl * this.config.clockDriftFactor) + 2; const effectiveTime = totalTime + driftTime; if (effectiveTime >= this.config.ttl) { return { success: false, successCount, successfulNodes: successfulResults.map((r) => r.nodeId), nodeResults: actualResults }; } return { success: hasQuorum, successCount, successfulNodes: successfulResults.map((r) => r.nodeId), nodeResults: actualResults }; } /** * Attempt to acquire lock on a single Redis node */ async acquireOnSingleNode(adapter, lockValue, nodeId) { const startTime = Date.now(); try { const result = await adapter.setNX(this.config.key, lockValue, this.config.ttl); const operationTime = Date.now() - startTime; return { success: result === REDIS_OK_RESPONSE3, adapter, nodeId, operationTime }; } catch (error) { const operationTime = Date.now() - startTime; return { success: false, adapter, nodeId, error: error instanceof Error ? error : new Error(ERROR_MESSAGES.UNKNOWN_ERROR), operationTime }; } } /** * Release any partially acquired locks to prevent deadlocks */ async releasePartialLocks(nodeResults, lockValue) { const releasePromises = nodeResults.filter((result) => result.success).map( (result) => result.adapter.delIfMatch(this.config.key, lockValue).catch(() => { }) ); await Promise.allSettled(releasePromises); } /** * Release a previously acquired distributed lock */ async release(handle) { this.validateHandle(handle); try { const releasePromises = this.adapters.map( (adapter) => adapter.delIfMatch(handle.key, handle.value) ); const results = await Promise.allSettled(releasePromises); const successfulReleases = results.filter( (result) => result.status === "fulfilled" && result.value === true ).length; return successfulReleases >= this.config.quorum; } catch (error) { throw new LockReleaseError( handle.key, "redis_error", error instanceof Error ? error : new Error(ERROR_MESSAGES.UNKNOWN_ERROR) ); } } /** * Extend the TTL of an existing distributed lock */ async extend(handle, ttl) { this.validateHandle(handle); if (ttl <= 0 || !Number.isInteger(ttl)) { throw new Error("TTL must be a positive integer"); } try { const checkPromises = this.adapters.map((adapter) => adapter.get(handle.key)); const checkResults = await Promise.allSettled(checkPromises); const validNodes = checkResults.filter((result) => { if (result.status === "rejected") return false; const value = result.value; return value !== null && safeCompare(value, handle.value); }); if (validNodes.length < this.config.quorum) { return false; } const extendPromises = this.adapters.map( (adapter) => adapter.extendIfMatch(handle.key, handle.value, ttl) ); const extendResults = await Promise.allSettled(extendPromises); const successfulExtensions = extendResults.filter( (result) => result.status === "fulfilled" && result.value === true ).length; return successfulExtensions >= this.config.quorum; } catch (error) { throw new LockExtensionError( handle.key, "redis_error", error instanceof Error ? error : new Error(ERROR_MESSAGES.UNKNOWN_ERROR) ); } } /** * Check if the distributed lock is currently held */ async isLocked(key) { try { const checkPromises = this.adapters.map((adapter) => adapter.get(key)); const results = await Promise.allSettled(checkPromises); const lockedNodes = results.filter( (result) => result.status === "fulfilled" && result.value !== null ).length; return lockedNodes >= this.config.quorum; } catch (error) { return false; } } /** * Validate lock handle */ validateHandle(handle) { if (!handle) { throw new Error("Lock handle is required"); } if (!handle.id || !handle.key || !handle.value) { throw new Error("Invalid lock handle: missing required properties"); } if (handle.key !== this.config.key) { throw new Error( `Lock handle key "${handle.key}" does not match RedLock key "${this.config.key}"` ); } } /** * Sleep for specified milliseconds */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get RedLock configuration (for debugging) */ getConfig() { return { ...this.config }; } /** * Get all underlying Redis adapters (for advanced usage) */ getAdapters() { return this.adapters; } /** * Get the underlying Redis adapter for atomic operations * RedLock manages multiple adapters, so returns null */ getAdapter() { return null; } /** * Get quorum requirement */ getQuorum() { return this.config.quorum; } /** * Execute a routine with automatic lock management and extension * Auto-extends when remaining TTL < 20% (extends at ~80% consumed) * Uses quorum-based extension strategy (continues if majority of nodes succeed) * Provides AbortSignal when extension fails */ async using(routine) { const handle = await this.acquire(); const baseConfig = { locks: [this], handles: [handle], ttl: this.config.ttl, routine }; return executeWithAutoExtension( this.config.logger ? { ...baseConfig, logger: this.config.logger } : baseConfig ); } }; // src/factory.ts function createLock(config) { if (!config) { throw new ConfigurationError("Lock configuration is required"); } if (!config.adapter) { throw new ConfigurationError("Redis adapter is required"); } if (!config.key) { throw new ConfigurationError("Lock key is required"); } const simpleLockConfig = { adapter: config.adapter, key: config.key, ...config.ttl !== void 0 && { ttl: config.ttl }, ...config.retryAttempts !== void 0 && { retryAttempts: config.retryAttempts }, ...config.retryDelay !== void 0 && { retryDelay: config.retryDelay } }; const performance = config.performance ?? "standard"; switch (performance) { case "lean": return new LeanSimpleLock(simpleLockConfig); case "enterprise": case "standard": default: return new SimpleLock(simpleLockConfig); } } function createLocks(adapter, keys, options = {}) { if (!adapter) { throw new ConfigurationError("Redis adapter is required"); } if (!keys || keys.length === 0) { throw new ConfigurationError("At least one lock key is required"); } return keys.map( (key) => createLock({ adapter, key, ...options }) ); } function createPrefixedLock(adapter, prefix, key, options = {}) { if (!prefix || !key) { throw new ConfigurationError("Both prefix and key are required"); } return createLock({ adapter, key: `${prefix}${key}`, ...options }); } function createRedlock(config) { if (!config) { throw new ConfigurationError("RedLock configuration is required"); } if (!config.adapters || config.adapters.length === 0) { throw new ConfigurationError("At least one Redis adapter is required for RedLock"); } if (!config.key) { throw new ConfigurationError("Lock key is required"); } const redlockConfig = { adapters: config.adapters, key: config.key, ...config.ttl !== void 0 && { ttl: config.ttl }, ...config.quorum !== void 0 && { quorum: config.quorum }, ...config.retryAttempts !== void 0 && { retryAttempts: config.retryAttempts }, ...config.retryDelay !== void 0 && { retryDelay: config.retryDelay }, ...config.clockDriftFactor !== void 0 && { clockDriftFactor: config.clockDriftFactor } }; return new RedLock(redlockConfig); } function createRedlocks(adapters, keys, options = {}) { if (!adapters || adapters.length === 0) { throw new ConfigurationError("At least one Redis adapter is required"); } if (!keys || keys.length === 0) { throw new ConfigurationError("At least one lock key is required"); } return keys.map( (key) => createRedlock({ adapters, key, ...options }) ); } // src/adapters/NodeRedisAdapter.ts var REDIS_ERROR_NOSCRIPT = "NOSCRIPT"; var MAX_SCRIPT_RETRY_ATTEMPTS = 1; var SCRIPT_CACHE_KEY_ATOMIC_EXTEND = "ATOMIC_EXTEND"; var SCRIPT_CACHE_KEY_BATCH_ACQUIRE = "BATCH_ACQUIRE"; var NodeRedisAdapter = class _NodeRedisAdapter extends BaseAdapter { client; constructor(client, options = {}) { super(options); this.client = client; } /** * Factory method to create adapter from client */ static from(client, options) { return new _NodeRedisAdapter(client, options); } /** * Execute a Lua script with automatic loading, caching, and NOSCRIPT retry handling * @private */ async _executeScript(scriptCacheKey, scriptBody, keys, args, retryAttempt = 0) { let scriptSHA = this.scriptSHAs.get(scriptCacheKey); if (!scriptSHA) { try { scriptSHA = await this.withTimeout(this.client.scriptLoad(scriptBody)); this.scriptSHAs.set(scriptCacheKey, scriptSHA); } catch (error) { throw new Error(`Failed to load script ${scriptCacheKey}`, { cause: error }); } } try { const result = await this.withTimeout( this.client.evalSha(scriptSHA, { keys, arguments: args.map((a) => a.toString()) }) ); return result; } catch (error) { const errorMessage = error.message; if (errorMessage.includes(REDIS_ERROR_NOSCRIPT)) { this.scriptSHAs.delete(scriptCacheKey); if (retryAttempt < MAX_SCRIPT_RETRY_ATTEMPTS) { return this._executeScript(scriptCacheKey, scriptBody, keys, args, retryAttempt + 1); } throw new Error( `Script execution failed after ${MAX_SCRIPT_RETRY_ATTEMPTS} NOSCRIPT retries`, { cause: error } ); } throw new Error("Script execution failed", { cause: error }); } } async setNX(key, value, ttl) { this.validateKey(key); this.validateValue(value); this.validateTTL(ttl); const prefixedKey = this.prefixKey(key); try { const result = await this.withTimeout( this.client.set(prefixedKey, value, { NX: true, PX: ttl }) ); return result; } catch (error) { throw new Error(`Failed to acquire lock: ${error.message}`); } } async get(key) { this.validateKey(key); const prefixedKey = this.prefixKey(key); try { return await this.withTimeout(this.client.get(prefixedKey)); } catch (error) { throw new Error(`Failed to get key: ${error.message}`); } } async del(key) { this.validateKey(key); const prefixedKey = this.prefixKey(key); try { return await this.withTimeout(this.client.del(prefixedKey)); } catch (error) { throw new Error(`Failed to delete key: ${error.message}`); } } async delIfMatch(key, value) { this.validateKey(key); this.validateValue(value); const prefixedKey = this.prefixKey(key); try { const result = await this.withTimeout( this.client.eval(DELETE_IF_MATCH_SCRIPT, { keys: [prefixedKey], arguments: [value] }) ); return result === 1; } catch (error) { throw new Error(`Failed to conditionally delete key: ${error.message}`); } } async extendIfMatch(key, value, ttl) { this.validateKey(key); this.validateValue(value); this.validateTTL(ttl); const prefixedKey = this.prefixKey(key); try { const result = await this.withTimeout( this.client.eval(EXTEND_IF_MATCH_SCRIPT, { keys: [prefixedKey], arguments: [value, ttl.toString()] }) ); return result === 1; } catch (error) { throw new Error(`Failed to extend lock TTL: ${error.message}`); } } async atomicExtend(key, value, minTTL, newTTL) { this.validateKey(key); this.validateValue(value); this.validateTTL(newTTL); if (!Number.isInteger(minTTL) || minTTL <= 0) { throw new TypeError("Minimum TTL must be a positive integer"); } const prefixedKey = this.prefixKey(key); const result = await this._executeScript( SCRIPT_CACHE_KEY_ATOMIC_EXTEND, ATOMIC_EXTEND_SCRIPT, [prefixedKey], [value, minTTL, newTTL] ); return this.interpretAtomicExtensionResult(prefixedKey, minTTL, result); } async batchSetNX(keys, values, ttl) { if (keys.length !== values.length) { throw new TypeError("Keys and values arrays must have the same length"); } if (keys.length === 0) { throw new TypeError("At least one key is required for batch acquisition"); } keys.forEach((k) => this.validateKey(k)); values.forEach((v) => this.validateValue(v)); this.validateTTL(ttl); const prefixedKeys = keys.map((k) => this.prefixKey(k)); const result = await this._executeScript( SCRIPT_CACHE_KEY_BATCH_ACQUIRE, BATCH_ACQUIRE_SCRIPT, prefixedKeys, [...values, ttl] ); const [resultCode, countOrIndex, failedKey] = result; if (resultCode === 1) { return { success: true, acquiredCount: countOrIndex }; } else { const keyThatFailed = failedKey ? this.stripPrefix(failedKey) : keys[countOrIndex - 1] ?? "unknown"; return { success: false, acquiredCount: 0, failedIndex: countOrIndex, failedKey: keyThatFailed }; } } async ping() { try { return await this.withTimeout(this.client.ping()); } catch (error) { throw new Error(`Ping failed: ${error.message}`); } } isConnected() { return this.client.isReady; } async disconnect() { try { this.scriptSHAs.clear(); await this.client.disconnect(); } catch (error) { if (this.options.logger) { this.options.logger.warn("Warning during Redis disconnect", { adapter: "node-redis", error: error.message }); } } } /** * Get the underlying node-redis client (for advanced usage) */ getClient() { return this.client; } }; // src/adapters/IoredisAdapter.ts var REDIS_STATUS_READY = "ready"; var REDIS_ERROR_NOSCRIPT2 = "NOSCRIPT"; var MAX_SCRIPT_RETRY_ATTEMPTS2 = 1; var SCRIPT_CACHE_KEY_ATOMIC_EXTEND2 = "ATOMIC_EXTEND"; var SCRIPT_CACHE_KEY_BATCH_ACQUIRE2 = "BATCH_ACQUIRE"; var IoredisAdapter = class _IoredisAdapter extends BaseAdapter { client; constructor(client, options = {}) { super(options); this.client = client; } /** * Factory method to create adapter from client */ static from(client, options) { return new _IoredisAdapter(client, options); } /** * Execute a Lua script with automatic loading, caching, and NOSCRIPT retry handling * @private */ async _executeScript(scriptCacheKey, scriptBody, keys, args, retryAttempt = 0) { let scriptSHA = this.scriptSHAs.get(scriptCacheKey); if (!scriptSHA) { try { scriptSHA = await this.withTimeout( this.client.script("LOAD", scriptBody) ); this.scriptSHAs.set(scriptCacheKey, scriptSHA); } catch (error) { throw new Error(`Failed to load script ${scriptCacheKey}`, { cause: error }); } } try { const result = await this.withTimeout( this.client.evalsha(scriptSHA, keys.length, ...keys, ...args.map((a) => a.toString())) ); return result; } catch (error) { const errorMessage = error.message; if (errorMessage.includes(REDIS_ERROR_NOSCRIPT2)) { this.scriptSHAs.delete(scriptCacheKey); if (retryAttempt < MAX_SCRIPT_RETRY_ATTEMPTS2) { return this._executeScript(scriptCacheKey, scriptBody, keys, args, retryAttempt + 1); } throw new Error( `Script execution failed after ${MAX_SCRIPT_RETRY_ATTEMPTS2} NOSCRIPT retries`, { cause: error } ); } throw new Error("Script execution failed", { cause: error }); } } async setNX(key, value, ttl) { this.validateKey(key); this.validateValue(value); this.validateTTL(ttl); const prefixedKey = this.prefixKey(key); try { const result = aw