UNPKG

redlock-universal

Version:

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

1,704 lines (1,684 loc) 54.9 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}`); } }; function generateLockValue() { return crypto.randomBytes(16).toString("hex"); } function generateLockId() { const timestamp = Date.now(); const random = crypto.randomBytes(6).toString("hex"); return `${timestamp}-${random}`; } function safeCompare(a, b) { if (a.length !== b.length) { return false; } const bufferA = Buffer.from(a, "utf8"); const bufferB = Buffer.from(b, "utf8"); return crypto.timingSafeEqual(bufferA, bufferB); } function createLockValueWithMetadata(nodeId) { const timestamp = Date.now(); const random = crypto.randomBytes(8).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/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 }; var LUA_SCRIPTS = { /** Script to safely release a lock (check value before delete) */ RELEASE: ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `.trim(), /** Script to safely extend a lock (check value before extend) */ EXTEND: ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end `.trim() }; 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/locks/SimpleLock.ts var SimpleLock = class { adapter; key; ttl; retryAttempts; retryDelay; 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.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 (process.env.NODE_ENV !== "test") { console.log("Circuit breaker closed - Redis recovered", { key: this.key, correlationId: this.correlationId, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } else { this._circuitBreakerFailures++; if (this._circuitBreakerState === "closed" && this._circuitBreakerFailures >= this._circuitBreakerThreshold) { this._circuitBreakerState = "open"; this._circuitBreakerOpenedAt = now; if (process.env.NODE_ENV !== "test") { console.error("Circuit breaker opened - Redis failing", { key: this.key, correlationId: this.correlationId, failures: this._circuitBreakerFailures, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } if (this._circuitBreakerState === "open" && now - this._circuitBreakerOpenedAt > this._circuitBreakerTimeout) { this._circuitBreakerState = "half-open"; if (process.env.NODE_ENV !== "test") { console.log("Circuit breaker half-open - testing Redis", { key: this.key, correlationId: this.correlationId, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } /** * Check if circuit breaker allows operation */ isCircuitBreakerOpen() { return this._circuitBreakerState === "open"; } /** * 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 (process.env.NODE_ENV !== "test") { console.log("Redis connection recovered", { key: this.key, correlationId: this.correlationId, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } catch (error) { this._isHealthy = false; this.updateCircuitBreaker(false); if (process.env.NODE_ENV !== "test") { console.error("Redis health check failed", { key: this.key, correlationId: this.correlationId, error: error.message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } } /** * Attempt to acquire the lock */ async acquire() { if (this.isCircuitBreakerOpen()) { 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 === "OK") { 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")) { console.error("Redis connection failed for lock", { key: this.key, correlationId: this.correlationId, attempt: attempt + 1, error: error.message, circuitBreaker: this._circuitBreakerState, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); } } 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 } }; } }; // src/locks/LeanSimpleLock.ts 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 === "OK") { 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; } } }; // src/locks/RedLock.ts var RedLock = class { adapters; config; constructor(config) { this.adapters = config.adapters; this.config = { adapters: config.adapters, key: config.key, ttl: config.ttl ?? DEFAULTS.TTL, quorum: config.quorum ?? Math.floor(config.adapters.length / 2) + 1, retryAttempts: config.retryAttempts ?? DEFAULTS.RETRY_ATTEMPTS, retryDelay: config.retryDelay ?? DEFAULTS.RETRY_DELAY * 2, // Distributed locks need more time clockDriftFactor: config.clockDriftFactor ?? DEFAULTS.CLOCK_DRIFT_FACTOR }; 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 === "OK", 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 quorum requirement */ getQuorum() { return this.config.quorum; } }; // 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/BaseAdapter.ts var BaseAdapter = class { options; constructor(options = {}) { this.options = { keyPrefix: options.keyPrefix ?? "", maxRetries: options.maxRetries ?? 3, retryDelay: options.retryDelay ?? DEFAULTS.RETRY_DELAY, timeout: options.timeout ?? DEFAULTS.REDIS_TIMEOUT, ...options }; } /** * 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 > 512) { throw new TypeError("Lock key must be less than 512 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 > 1024) { throw new TypeError("Lock value must be less than 1024 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 > 864e5) { throw new TypeError("TTL cannot exceed 24 hours (86400000ms)"); } } /** * Adds prefix to key if configured */ prefixKey(key) { return this.options.keyPrefix ? `${this.options.keyPrefix}${key}` : key; } /** * Handles timeout for Redis operations */ async withTimeout(operation, timeoutMs = this.options.timeout) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs); }); return Promise.race([operation, timeoutPromise]); } }; // src/adapters/NodeRedisAdapter.ts 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); } /** * Set key with value if not exists, with TTL in milliseconds */ 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}`); } } /** * Get value for key */ 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}`); } } /** * Delete key */ 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}`); } } /** * Delete key only if value matches (atomic operation) * Uses Lua script to ensure atomicity */ async delIfMatch(key, value) { this.validateKey(key); this.validateValue(value); const prefixedKey = this.prefixKey(key); const script = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `; try { const result = await this.withTimeout( this.client.eval(script, { keys: [prefixedKey], arguments: [value] }) ); return result === 1; } catch (error) { throw new Error(`Failed to conditionally delete key: ${error.message}`); } } /** * Extend TTL of key only if value matches (atomic operation) * Uses Lua script to ensure atomicity */ async extendIfMatch(key, value, ttl) { this.validateKey(key); this.validateValue(value); this.validateTTL(ttl); const prefixedKey = this.prefixKey(key); const script = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end `; try { const result = await this.withTimeout( this.client.eval(script, { keys: [prefixedKey], arguments: [value, ttl.toString()] }) ); return result === 1; } catch (error) { throw new Error(`Failed to extend lock TTL: ${error.message}`); } } /** * Ping Redis server */ async ping() { try { return await this.withTimeout(this.client.ping()); } catch (error) { throw new Error(`Ping failed: ${error.message}`); } } /** * Check if client is connected */ isConnected() { return this.client.isReady; } /** * Disconnect from Redis */ async disconnect() { try { await this.client.disconnect(); } catch (error) { if (process.env.NODE_ENV === "development") { process.stderr.write(`Warning during disconnect: ${error.message} `); } } } /** * Get the underlying node-redis client (for advanced usage) */ getClient() { return this.client; } }; // src/adapters/IoredisAdapter.ts 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); } /** * Set key with value if not exists, with TTL in milliseconds */ 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, "PX", ttl, "NX")); return result; } catch (error) { throw new Error(`Failed to acquire lock: ${error.message}`); } } /** * Get value for key */ 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}`); } } /** * Delete key */ 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}`); } } /** * Delete key only if value matches (atomic operation) * Uses Lua script to ensure atomicity */ async delIfMatch(key, value) { this.validateKey(key); this.validateValue(value); const prefixedKey = this.prefixKey(key); const script = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `; try { const result = await this.withTimeout( this.client.eval(script, 1, prefixedKey, value) ); return result === 1; } catch (error) { throw new Error(`Failed to conditionally delete key: ${error.message}`); } } /** * Extend TTL of key only if value matches (atomic operation) * Uses Lua script to ensure atomicity */ async extendIfMatch(key, value, ttl) { this.validateKey(key); this.validateValue(value); this.validateTTL(ttl); const prefixedKey = this.prefixKey(key); const script = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end `; try { const result = await this.withTimeout( this.client.eval(script, 1, prefixedKey, value, ttl.toString()) ); return result === 1; } catch (error) { throw new Error(`Failed to extend lock TTL: ${error.message}`); } } /** * Ping Redis server */ async ping() { try { return await this.withTimeout(this.client.ping()); } catch (error) { throw new Error(`Ping failed: ${error.message}`); } } /** * Check if client is connected */ isConnected() { return this.client.status === "ready"; } /** * Disconnect from Redis */ async disconnect() { try { this.client.disconnect(); } catch (error) { if (process.env.NODE_ENV === "development") { process.stderr.write(`Warning during disconnect: ${error.message} `); } } } /** * Get the underlying ioredis client (for advanced usage) */ getClient() { return this.client; } }; // src/manager/LockManager.ts var LockManager = class { config; activeLocks = /* @__PURE__ */ new Map(); stats = { totalLocks: 0, activeLocks: 0, acquiredLocks: 0, failedLocks: 0, acquisitionTimes: [], holdTimes: [] }; constructor(config) { this.config = { nodes: config.nodes, defaultTTL: config.defaultTTL ?? DEFAULTS.TTL, defaultRetryAttempts: config.defaultRetryAttempts ?? DEFAULTS.RETRY_ATTEMPTS, defaultRetryDelay: config.defaultRetryDelay ?? DEFAULTS.RETRY_DELAY, monitoring: { enabled: config.monitoring?.enabled ?? false, metricsPort: config.monitoring?.metricsPort ?? 9090, healthCheckInterval: config.monitoring?.healthCheckInterval ?? 3e4 } }; this.validateConfig(); } /** * Validate configuration parameters */ validateConfig() { if (!this.config.nodes || this.config.nodes.length === 0) { throw new Error("At least one Redis node is required"); } if (this.config.defaultTTL <= 0) { throw new Error("Default TTL must be positive"); } if (this.config.defaultRetryAttempts < 0) { throw new Error("Default retry attempts must be non-negative"); } if (this.config.defaultRetryDelay < 0) { throw new Error("Default retry delay must be non-negative"); } } /** * Create a simple lock for single Redis instance */ createSimpleLock(key, options = {}) { const nodeIndex = options.nodeIndex ?? 0; if (nodeIndex >= this.config.nodes.length) { throw new Error(`Node index ${nodeIndex} is out of range`); } return new SimpleLock({ adapter: this.config.nodes[nodeIndex], key, ttl: options.ttl ?? this.config.defaultTTL, retryAttempts: options.retryAttempts ?? this.config.defaultRetryAttempts, retryDelay: options.retryDelay ?? this.config.defaultRetryDelay }); } /** * Create a distributed RedLock for multiple Redis instances */ createRedLock(key, options = {}) { if (this.config.nodes.length < 3) { throw new Error("RedLock requires at least 3 Redis nodes for proper distributed locking"); } return new RedLock({ adapters: this.config.nodes, key, ttl: options.ttl ?? this.config.defaultTTL, retryAttempts: options.retryAttempts ?? this.config.defaultRetryAttempts, retryDelay: options.retryDelay ?? this.config.defaultRetryDelay, quorum: options.quorum ?? Math.floor(this.config.nodes.length / 2) + 1, clockDriftFactor: options.clockDriftFactor ?? 0.01 }); } /** * Acquire a lock with automatic tracking */ async acquireLock(key, options = {}) { const startTime = Date.now(); this.stats.totalLocks++; try { const lock = options.useRedLock ? this.createRedLock(key, options) : this.createSimpleLock(key, options); const handle = await lock.acquire(); const acquisitionTime = Date.now() - startTime; this.stats.acquisitionTimes.push(acquisitionTime); this.stats.acquiredLocks++; this.stats.activeLocks++; this.activeLocks.set(handle.id, handle); return handle; } catch (error) { this.stats.failedLocks++; throw error; } } /** * Release a tracked lock */ async releaseLock(handle) { const holdTime = Date.now() - handle.acquiredAt; this.stats.holdTimes.push(holdTime); this.activeLocks.delete(handle.id); this.stats.activeLocks--; const lock = handle.metadata?.strategy === "redlock" ? this.createRedLock(handle.key) : this.createSimpleLock(handle.key); return lock.release(handle); } /** * Get current lock statistics */ getStats() { const avgAcquisitionTime = this.stats.acquisitionTimes.length > 0 ? this.stats.acquisitionTimes.reduce((a, b) => a + b, 0) / this.stats.acquisitionTimes.length : 0; const avgHoldTime = this.stats.holdTimes.length > 0 ? this.stats.holdTimes.reduce((a, b) => a + b, 0) / this.stats.holdTimes.length : 0; return { totalLocks: this.stats.totalLocks, activeLocks: this.stats.activeLocks, acquiredLocks: this.stats.acquiredLocks, failedLocks: this.stats.failedLocks, averageAcquisitionTime: avgAcquisitionTime, averageHoldTime: avgHoldTime }; } /** * Get list of currently active locks */ getActiveLocks() { return Array.from(this.activeLocks.values()); } /** * Check health of all Redis nodes */ async checkHealth() { const nodeResults = await Promise.allSettled( this.config.nodes.map(async (adapter, index) => { try { const result = await adapter.ping(); return { index, healthy: result === "PONG" }; } catch (error) { return { index, healthy: false, error: error instanceof Error ? error.message : ERROR_MESSAGES.UNKNOWN_ERROR }; } }) ); const nodes = nodeResults.map( (result, index) => result.status === "fulfilled" ? result.value : { index, healthy: false, error: "Health check failed" } ); const healthyCount = nodes.filter((node) => node.healthy).length; const healthy = healthyCount >= Math.ceil(this.config.nodes.length / 2); return { healthy, nodes }; } /** * Clean up expired locks from tracking */ async cleanupExpiredLocks() { const now = Date.now(); const expiredLocks = []; for (const [id, handle] of this.activeLocks) { if (handle.acquiredAt + handle.ttl < now) { expiredLocks.push(id); } } for (const id of expiredLocks) { this.activeLocks.delete(id); this.stats.activeLocks--; } return expiredLocks.length; } /** * Get metrics in Prometheus format (if monitoring enabled) */ getMetrics() { if (!this.config.monitoring.enabled) { return ""; } const stats = this.getStats(); return ` # HELP redlock_locks_total Total number of lock operations # TYPE redlock_locks_total counter redlock_locks_total ${stats.totalLocks} # HELP redlock_locks_active Current number of active locks # TYPE redlock_locks_active gauge redlock_locks_active ${stats.activeLocks} # HELP redlock_locks_acquired_total Total number of successfully acquired locks # TYPE redlock_locks_acquired_total counter redlock_locks_acquired_total ${stats.acquiredLocks} # HELP redlock_locks_failed_total Total number of failed lock operations # TYPE redlock_locks_failed_total counter redlock_locks_failed_total ${stats.failedLocks} # HELP redlock_acquisition_duration_ms Average lock acquisition time in milliseconds # TYPE redlock_acquisition_duration_ms gauge redlock_acquisition_duration_ms ${stats.averageAcquisitionTime} # HELP redlock_hold_duration_ms Average lock hold time in milliseconds # TYPE redlock_hold_duration_ms gauge redlock_hold_duration_ms ${stats.averageHoldTime} `.trim(); } }; // src/monitoring/MetricsCollector.ts var MetricsCollector = class { lockMetrics = []; maxMetrics; constructor(maxMetrics = 1e3) { this.maxMetrics = maxMetrics; } /** * Record a lock operation metric */ recordLockOperation(metrics) { this.lockMetrics.push(metrics); if (this.lockMetrics.length > this.maxMetrics) { this.lockMetrics.shift(); } } /** * Get summary of all recorded metrics */ getSummary() { if (this.lockMetrics.length === 0) { return { totalOperations: 0, successfulOperations: 0, failedOperations: 0, averageAcquisitionTime: 0, p95AcquisitionTime: 0, p99AcquisitionTime: 0, successRate: 0 }; } const successful = this.lockMetrics.filter((m) => m.success); const acquisitionTimes = successful.map((m) => m.acquisitionTime).sort((a, b) => a - b); const p95Index = Math.floor(acquisitionTimes.length * 0.95); const p99Index = Math.floor(acquisitionTimes.length * 0.99); return { totalOperations: this.lockMetrics.length, successfulOperations: successful.length, failedOperations: this.lockMetrics.length - successful.length, averageAcquisitionTime: acquisitionTimes.length > 0 ? acquisitionTimes.reduce((sum, time) => sum + time, 0) / acquisitionTimes.length : 0, p95AcquisitionTime: acquisitionTimes[p95Index] || 0, p99AcquisitionTime: acquisitionTimes[p99Index] || 0, successRate: this.lockMetrics.length > 0 ? successful.length / this.lockMetrics.length : 0 }; } /** * Get metrics for a specific time window */ getMetricsForWindow(windowMs) { const cutoff = Date.now() - windowMs; return this.lockMetrics.filter((m) => m.timestamp >= cutoff); } /** * Get metrics grouped by key */ getMetricsByKey() { const byKey = /* @__PURE__ */ new Map(); for (const metric of this.lockMetrics) { const existing = byKey.get(metric.key) || []; existing.push(metric); byKey.set(metric.key, existing); } return byKey; } /** * Clear all recorded metrics */ clear() { this.lockMetrics.length = 0; } /** * Get current metrics count */ getMetricsCount() { return this.lockMetrics.length; } }; // src/monitoring/HealthChecker.ts var HealthChecker = class { adapters = /* @__PURE__ */ new Map(); healthHistory = /* @__PURE__ */ new Map(); maxHistorySize; constructor(maxHistorySize = 100) { this.maxHistorySize = maxHistorySize; } /** * Register an adapter for health monitoring */ registerAdapter(name, adapter) { this.adapters.set(name, adapter); this.healthHistory.set(name, []); } /** * Unregister an adapter */ unregisterAdapter(name) { this.adapters.delete(name); this.healthHistory.delete(name); } /** * Check health of a specific adapter */ async checkAdapterHealth(name) { const adapter = this.adapters.get(name); if (!adapter) { return { healthy: false, timestamp: Date.now(), responseTime: 0, error: `Adapter '${name}' not registered` }; } const startTime = Date.now(); try { const testKey = `health:check:${Date.now()}:${Math.random().toString(36).slice(2)}`; const testValue = "ping"; const setResult = await adapter.setNX(testKey, testValue, 1e3); const retrieved = await adapter.get(testKey); const responseTime = Date.now() - startTime; const healthy = setResult === "OK" && retrieved === testValue; try { await adapter.del(testKey); } catch { } const status = { healthy, timestamp: Date.now(), responseTime, ...healthy ? {} : { error: "Health check value mismatch" } }; this.recordHealthStatus(name, status); return status; } catch (error) { const status = { healthy: false, timestamp: Date.now(), responseTime: Date.now() - startTime, error: error instanceof Error ? error.message : "Unknown error" }; this.recordHealthStatus(name, status); return status; } } /** * Check health of all registered adapters */ async checkSystemHealth() { const adapterNames = Array.from(this.adapters.keys()); const healthChecks = await Promise.allSettled( adapterNames.map(async (name) => ({ adapter: name, status: await this.checkAdapterHealth(name) })) ); const adapters = healthChecks.map((result, index) => { if (result.status === "fulfilled") { return result.value; } else { return { adapter: adapterNames[index], status: { healthy: false, timestamp: Date.now(), responseTime: 0, error: "Health check failed" } }; } }); const overall = adapters.length > 0 && adapters.every((a) => a.status.healthy); return { overall, adapters, timestamp: Date.now() }; } /** * Get health history for an adapter */ getHealthHistory(name, count) { const history = this.healthHistory.get(name) || []; return count ? history.slice(-count) : [...history]; } /** * Get health statistics for an adapter */ getHealthStats(name, windowMs) { let history = this.healthHistory.get(name) || []; if (windowMs) { const cutoff = Date.now() - windowMs; history = history.filter((h) => h.timestamp >= cutoff); } if (history.length === 0) { return { total: 0, healthy: 0, unhealthy: 0, averageResponseTime: 0, uptime: 0 }; } const healthy = history.filter((h) => h.healthy); const responseTimes = history.map((h) => h.responseTime); const averageResponseTime = responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length; return { total: history.length, healthy: healthy.length, unhealthy: history.length - healthy.length, averageResponseTime, uptime: healthy.length / history.length }; } /** * Clear health history for an adapter */ clearHistory(name) { this.healthHistory.set(name, []); } /** * Clear all health history */ clearAllHistory() { for (const name of this.healthHistory.keys()) { this.healthHistory.set(name, []); } } recordHealthStatus(name, status) { const history = this.healthHistory.get(name) || []; history.push(status);