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
JavaScript
'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