import { v4 as uuidv4 } from 'uuid'; import { Lock, LockManager, LockOptions } from '../interfaces/lock'; import { getLogger } from '../utils/logger'; /** * Options for memory lock manager */ export interface MemoryLockOptions { /** * 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 logging */ enableLogging?: boolean; /** * Log level */ logLevel?: 'debug' | 'info' | 'warn' | 'error'; } /** * Memory-based lock manager * Useful for single-process applications or testing */ export class MemoryLockManager implements LockManager { private locks: Map = new Map(); private options: MemoryLockOptions; private logger: any; private autoExtenders: Map = new Map(); private expirationTimers: Map = new Map(); /** * Creates a new MemoryLockManager * @param options Lock manager options */ constructor(options: MemoryLockOptions = {}) { this.options = { defaultTtl: 30000, // 30 seconds defaultRetryDelay: 200, // 200ms defaultMaxRetries: 10, enableLogging: false, logLevel: 'info', ...options }; this.logger = getLogger('lock:memory', 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 effectiveTtl = ttl || this.options.defaultTtl!; // Check if resource is already locked if (this.isLocked(resource)) { // If force option is enabled, release the existing lock if (options.force) { const existingLock = this.locks.get(resource)!; await this.release(existingLock); this.logger.warn(`Forced release of lock on ${resource}`, { id: existingLock.id }); } else if (!options.wait) { // If not waiting and not forcing, return null return null; } } // Try to acquire the lock if not locked if (!this.isLocked(resource)) { const lock: Lock = { id: uuidv4(), resource, owner: options.owner || uuidv4(), acquiredAt: Date.now(), expiresAt: Date.now() + effectiveTtl, metadata: options.metadata }; // Store the lock this.locks.set(resource, lock); // Set up expiration timer this.setupExpirationTimer(lock); // Set up auto-extension if requested if (options.autoExtend) { this.setupAutoExtension(lock, effectiveTtl, options.autoExtendInterval); } this.logger.debug(`Acquired lock on ${resource}`, { id: lock.id, ttl: effectiveTtl }); 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)); // Check again if resource is locked if (!this.isLocked(resource)) { // Try to acquire the lock const lock: Lock = { id: uuidv4(), resource, owner: options.owner || uuidv4(), acquiredAt: Date.now(), expiresAt: Date.now() + effectiveTtl, metadata: options.metadata }; // Store the lock this.locks.set(resource, lock); // Set up expiration timer this.setupExpirationTimer(lock); // Set up auto-extension if requested if (options.autoExtend) { this.setupAutoExtension(lock, effectiveTtl, options.autoExtendInterval); } this.logger.debug(`Acquired lock on ${resource} after ${attempts + 1} attempts`, { id: lock.id, ttl: effectiveTtl }); 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 { // Check if lock exists and belongs to the same owner const existingLock = this.locks.get(lock.resource); if (existingLock && existingLock.owner === lock.owner) { // Remove the lock this.locks.delete(lock.resource); // Clear timers this.clearExpirationTimer(lock); this.stopAutoExtension(lock); this.logger.debug(`Released lock on ${lock.resource}`, { id: lock.id }); return true; } 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 false; } /** * 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 { // Check if lock exists and belongs to the same owner const existingLock = this.locks.get(lock.resource); if (existingLock && existingLock.owner === lock.owner) { // Update expiration time const updatedLock: Lock = { ...existingLock, expiresAt: Date.now() + ttl }; // Update the lock this.locks.set(lock.resource, updatedLock); // Update expiration timer this.clearExpirationTimer(lock); this.setupExpirationTimer(updatedLock); 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 lock = this.locks.get(resource); if (lock && lock.expiresAt > Date.now()) { return lock; } return null; } /** * Get all locks * @returns Promise resolving to a map of resource-to-lock mappings */ async getAll(): Promise> { const result = new Map(); // Filter out expired locks const now = Date.now(); for (const [resource, lock] of this.locks.entries()) { if (lock.expiresAt > now) { result.set(resource, lock); } } return result; } /** * Clear all locks (for administrative purposes) * @returns Promise resolving to true if successful */ async clear(): Promise { // Clear all auto-extenders for (const timer of this.autoExtenders.values()) { clearInterval(timer); } this.autoExtenders.clear(); // Clear all expiration timers for (const timer of this.expirationTimers.values()) { clearTimeout(timer); } this.expirationTimers.clear(); // Clear all locks const count = this.locks.size; this.locks.clear(); this.logger.info(`Cleared ${count} locks`); return true; } /** * 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); }); } } /** * Check if a resource is currently locked * @param resource Resource to check * @returns True if the resource is locked */ private isLocked(resource: string): boolean { const lock = this.locks.get(resource); if (!lock) { return false; } // Check if lock has expired if (lock.expiresAt <= Date.now()) { // Remove expired lock this.locks.delete(resource); this.clearExpirationTimer(lock); return false; } return true; } /** * Set up expiration timer for a lock * @param lock Lock to expire */ private setupExpirationTimer(lock: Lock): void { // Clear any existing timer this.clearExpirationTimer(lock); // Calculate time until expiration const timeUntilExpiration = Math.max(0, lock.expiresAt - Date.now()); // Set up timer to remove the lock when it expires const timer = setTimeout(() => { if (this.locks.get(lock.resource)?.id === lock.id) { this.locks.delete(lock.resource); this.logger.debug(`Lock on ${lock.resource} expired`, { id: lock.id }); } }, timeUntilExpiration); // Store timer reference this.expirationTimers.set(lock.id, timer); } /** * Clear expiration timer for a lock * @param lock Lock to clear timer for */ private clearExpirationTimer(lock: Lock): void { const timer = this.expirationTimers.get(lock.id); if (timer) { clearTimeout(timer); this.expirationTimers.delete(lock.id); } } /** * 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 }); } } }