import { Redis as IORedis } from 'ioredis'; import { v4 as uuidv4 } from 'uuid'; import { Lock, LockManager, LockOptions } from '../interfaces/lock'; import { getLogger } from '../utils/logger'; /** * Options for Redis lock manager */ export interface RedisLockOptions { /** * Key prefix for locks */ lockPrefix?: string; /** * Default lock TTL in milliseconds */ defaultTtl?: number; /** * Default retry delay in milliseconds */ defaultRetryDelay?: number; /** * Default maximum number of retry attempts */ defaultMaxRetries?: number; /** * Whether to enable drift protection * Prevents locks from being deleted by other processes after they expire but before they're released */ driftFactor?: number; /** * Whether to enable logging */ enableLogging?: boolean; /** * Log level */ logLevel?: 'debug' | 'info' | 'warn' | 'error'; } /** * Redis-based distributed lock manager * Uses Redis to coordinate locks across multiple processes or servers */ export class RedisLockManager implements LockManager { private client: IORedis; private options: RedisLockOptions; private logger: any; private autoExtenders: Map = new Map(); // Redis Lua script for atomic lock release // Only release if lock exists and owner matches private static readonly RELEASE_SCRIPT = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; // Redis Lua script for atomic lock extension // Only extend if lock exists and owner matches private static readonly EXTEND_SCRIPT = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end `; /** * Creates a new RedisLockManager * @param client Redis client * @param options Lock manager options */ constructor(client: IORedis, options: RedisLockOptions = {}) { this.client = client; this.options = { lockPrefix: 'lock:', defaultTtl: 30000, // 30 seconds defaultRetryDelay: 200, // 200ms defaultMaxRetries: 10, driftFactor: 0.01, // 1% of TTL enableLogging: false, logLevel: 'info', ...options }; this.logger = getLogger('lock:redis', this.options); } /** * Acquire a lock on a resource * @param resource Resource to lock * @param ttl Time-to-live for the lock in milliseconds * @param options Additional lock options * @returns Promise resolving to the acquired lock or null if not acquired */ async acquire(resource: string, ttl: number = this.options.defaultTtl!, options: LockOptions = {}): Promise { const lockKey = this.getLockKey(resource); const lockValue = options.owner || uuidv4(); const effectiveTtl = ttl || this.options.defaultTtl!; // Try to acquire the lock const acquired = await this.client.set(lockKey, lockValue, 'PX', effectiveTtl, 'NX'); if (acquired === 'OK') { const lock: Lock = { id: uuidv4(), resource, owner: lockValue, acquiredAt: Date.now(), expiresAt: Date.now() + effectiveTtl, metadata: options.metadata }; this.logger.debug(`Acquired lock on ${resource}`, { id: lock.id, ttl: effectiveTtl }); // Set up auto-extension if requested if (options.autoExtend) { this.setupAutoExtension(lock, effectiveTtl, options.autoExtendInterval); } return lock; } // If wait option is enabled, retry until acquired or max retries reached if (options.wait) { const retryDelay = options.retryDelay || this.options.defaultRetryDelay!; const maxRetries = options.maxRetries || this.options.defaultMaxRetries!; const waitTimeout = options.waitTimeout; let attempts = 0; const startTime = Date.now(); while (attempts < maxRetries) { // Check if wait timeout has been exceeded if (waitTimeout && (Date.now() - startTime) > waitTimeout) { this.logger.debug(`Wait timeout exceeded for lock on ${resource}`); return null; } // Wait before retrying await new Promise(resolve => setTimeout(resolve, retryDelay)); // Try to acquire again const acquired = await this.client.set(lockKey, lockValue, 'PX', effectiveTtl, 'NX'); if (acquired === 'OK') { const lock: Lock = { id: uuidv4(), resource, owner: lockValue, acquiredAt: Date.now(), expiresAt: Date.now() + effectiveTtl, metadata: options.metadata }; this.logger.debug(`Acquired lock on ${resource} after ${attempts + 1} attempts`, { id: lock.id, ttl: effectiveTtl }); // Set up auto-extension if requested if (options.autoExtend) { this.setupAutoExtension(lock, effectiveTtl, options.autoExtendInterval); } return lock; } attempts++; } this.logger.debug(`Failed to acquire lock on ${resource} after ${attempts} attempts`); } return null; } /** * Release a previously acquired lock * @param lock The lock to release * @returns Promise resolving to true if the lock was released */ async release(lock: Lock): Promise { const lockKey = this.getLockKey(lock.resource); // Stop auto-extension if active this.stopAutoExtension(lock); // Use Lua script for atomic release const result = await this.client.eval( RedisLockManager.RELEASE_SCRIPT, 1, lockKey, lock.owner ); const released = result === 1; if (released) { this.logger.debug(`Released lock on ${lock.resource}`, { id: lock.id }); } else { this.logger.warn(`Failed to release lock on ${lock.resource}`, { id: lock.id, reason: 'Lock may have expired or been acquired by another process' }); } return released; } /** * Extend the TTL of an existing lock * @param lock The lock to extend * @param ttl Additional time in milliseconds to add to the lock TTL * @returns Promise resolving to the updated lock or null if extension failed */ async extend(lock: Lock, ttl: number): Promise { const lockKey = this.getLockKey(lock.resource); // Use Lua script for atomic extension const result = await this.client.eval( RedisLockManager.EXTEND_SCRIPT, 1, lockKey, lock.owner, ttl.toString() ); if (result === 1) { // Update lock expiration const updatedLock: Lock = { ...lock, expiresAt: Date.now() + ttl }; this.logger.debug(`Extended lock on ${lock.resource} by ${ttl}ms`, { id: lock.id }); return updatedLock; } this.logger.warn(`Failed to extend lock on ${lock.resource}`, { id: lock.id, reason: 'Lock may have expired or been acquired by another process' }); return null; } /** * Check if a lock exists on a resource * @param resource Resource to check * @returns Promise resolving to the existing lock or null if not locked */ async check(resource: string): Promise { const lockKey = this.getLockKey(resource); // Get the lock value (owner) const owner = await this.client.get(lockKey); if (!owner) { return null; } // Get the remaining TTL const ttlMs = await this.client.pttl(lockKey); if (ttlMs <= 0) { return null; } const lock: Lock = { id: '[existing]', // We don't know the original ID resource, owner, acquiredAt: Date.now() - (ttlMs + 1), // Approximate expiresAt: Date.now() + ttlMs, metadata: undefined }; return lock; } /** * Get all locks * @returns Promise resolving to a map of resource-to-lock mappings */ async getAll(): Promise> { const result = new Map(); // Scan for all lock keys const stream = this.client.scanStream({ match: `${this.options.lockPrefix}*`, count: 100 }); return new Promise((resolve, reject) => { stream.on('data', async (keys: string[]) => { if (keys.length === 0) return; stream.pause(); try { // Process each key for (const key of keys) { const resource = this.getResourceFromKey(key); const lock = await this.check(resource); if (lock) { result.set(resource, lock); } } stream.resume(); } catch (error) { reject(error); } }); stream.on('end', () => { resolve(result); }); stream.on('error', (error) => { reject(error); }); }); } /** * Clear all locks (for administrative purposes) * @returns Promise resolving to true if successful */ async clear(): Promise { try { // Get all auto-extenders and stop them for (const [lockId, timer] of this.autoExtenders.entries()) { clearInterval(timer); this.autoExtenders.delete(lockId); } // Scan for all lock keys const stream = this.client.scanStream({ match: `${this.options.lockPrefix}*`, count: 100 }); let deletedCount = 0; await new Promise((resolve, reject) => { stream.on('data', async (keys: string[]) => { if (keys.length === 0) return; stream.pause(); try { // Delete keys in batches if (keys.length > 0) { const deleted = await this.client.del(...keys); deletedCount += deleted; } stream.resume(); } catch (error) { reject(error); } }); stream.on('end', () => { resolve(); }); stream.on('error', (error) => { reject(error); }); }); this.logger.info(`Cleared ${deletedCount} locks`); return true; } catch (error) { this.logger.error('Failed to clear locks', error); return false; } } /** * Execute a function within a lock * @param resource Resource to lock * @param fn Function to execute with the lock * @param ttl Time-to-live for the lock in milliseconds * @param options Additional lock options * @returns Promise resolving to the function result */ async withLock( resource: string, fn: (lock: Lock) => Promise, ttl: number = this.options.defaultTtl!, options: LockOptions = {} ): Promise { // Set wait to true by default for withLock const lockOptions: LockOptions = { wait: true, ...options }; // Acquire the lock const lock = await this.acquire(resource, ttl, lockOptions); if (!lock) { throw new Error(`Failed to acquire lock on ${resource}`); } try { // Execute the function return await fn(lock); } finally { // Always release the lock await this.release(lock).catch(error => { this.logger.error(`Error releasing lock on ${resource}`, error); }); } } /** * Set up automatic extension for a lock * @param lock Lock to extend * @param ttl Lock TTL in milliseconds * @param interval Extension interval in milliseconds */ private setupAutoExtension(lock: Lock, ttl: number, interval?: number): void { // Stop any existing extension this.stopAutoExtension(lock); // Calculate extension interval (default to 2/3 of TTL) const extensionInterval = interval || Math.floor(ttl * 2 / 3); // Create interval timer const timer = setInterval(async () => { try { const extended = await this.extend(lock, ttl); if (!extended) { // If extension fails, stop the auto-extension this.stopAutoExtension(lock); } } catch (error) { this.logger.error(`Error auto-extending lock on ${lock.resource}`, error); this.stopAutoExtension(lock); } }, extensionInterval); // Store timer reference this.autoExtenders.set(lock.id, timer); this.logger.debug(`Set up auto-extension for lock on ${lock.resource}`, { id: lock.id, interval: extensionInterval, ttl }); } /** * Stop automatic extension for a lock * @param lock Lock to stop extending */ private stopAutoExtension(lock: Lock): void { const timer = this.autoExtenders.get(lock.id); if (timer) { clearInterval(timer); this.autoExtenders.delete(lock.id); this.logger.debug(`Stopped auto-extension for lock on ${lock.resource}`, { id: lock.id }); } } /** * Get the Redis key for a lock * @param resource Resource name * @returns Redis key */ private getLockKey(resource: string): string { return `${this.options.lockPrefix}${resource}`; } /** * Get the resource name from a lock key * @param key Lock key * @returns Resource name */ private getResourceFromKey(key: string): string { return key.substring(this.options.lockPrefix!.length); } }