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
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}`);
}
};
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);