UNPKG

@shagital/atomic-lock

Version:

Universal atomic locking with pluggable drivers (Redis, SQLite, File, Memory)

606 lines (456 loc) 20.2 kB
import { AtomicLock, createLock } from '../src/core/atomic-lock' import { LockDriver, DriverConfig, LockOptions } from '../src/types' import { RedisLockDriver } from '../src/drivers/redis-driver' import { FileLockDriver } from '../src/drivers/file-driver' import { SQLiteLockDriver } from '../src/drivers/sqlite-driver' import { MemoryLockDriver } from '../src/drivers/memory-driver' // Mock all drivers jest.mock('../src/drivers/redis-driver') jest.mock('../src/drivers/file-driver') jest.mock('../src/drivers/sqlite-driver') jest.mock('../src/drivers/memory-driver') jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid-123'), })) const MockRedisLockDriver = RedisLockDriver as jest.MockedClass<typeof RedisLockDriver> const MockFileLockDriver = FileLockDriver as jest.MockedClass<typeof FileLockDriver> const MockSQLiteLockDriver = SQLiteLockDriver as jest.MockedClass<typeof SQLiteLockDriver> const MockMemoryLockDriver = MemoryLockDriver as jest.MockedClass<typeof MemoryLockDriver> describe('AtomicLock', () => { let mockDriver: jest.Mocked<LockDriver> let consoleErrorSpy: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() // Create mock driver with only the methods used by AtomicLock mockDriver = { tryAcquire: jest.fn(), release: jest.fn(), tryAcquireMultiple: jest.fn(), releaseMultiple: jest.fn(), } // Mock console.error consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() // Setup default mock implementations MockRedisLockDriver.mockImplementation(() => mockDriver as any) MockFileLockDriver.mockImplementation(() => mockDriver as any) MockSQLiteLockDriver.mockImplementation(() => mockDriver as any) MockMemoryLockDriver.mockImplementation(() => mockDriver as any) }) afterEach(() => { consoleErrorSpy.mockRestore() }) describe('Constructor and Driver Creation', () => { it('should create Redis driver', () => { const config: DriverConfig = { driver: 'redis', redis: { host: 'localhost', port: 6379 }, } new AtomicLock(config) expect(MockRedisLockDriver).toHaveBeenCalledWith(config.redis) }) it('should create File driver', () => { const config: DriverConfig = { driver: 'file', file: { lockDir: '/tmp/locks' }, } new AtomicLock(config) expect(MockFileLockDriver).toHaveBeenCalledWith(config.file) }) it('should create SQLite driver', () => { const config: DriverConfig = { driver: 'sqlite', sqlite: { db: '/tmp/locks.db' }, } new AtomicLock(config) expect(MockSQLiteLockDriver).toHaveBeenCalledWith(config.sqlite) }) it('should create Memory driver', () => { const config: DriverConfig = { driver: 'memory', memory: {}, } new AtomicLock(config) expect(MockMemoryLockDriver).toHaveBeenCalledWith(config.memory) }) it('should throw error for unsupported driver', () => { const config: DriverConfig = { driver: 'unsupported' as any, } expect(() => new AtomicLock(config)).toThrow('Unsupported driver: unsupported') }) it('should use default options when none provided', () => { const config: DriverConfig = { driver: 'memory', memory: {} } const lock = new AtomicLock(config) // Test default values by checking circuit breaker behavior const status = lock.getCircuitBreakerStatus('test-key') expect(status.failureCount).toBe(0) expect(status.isOpen).toBe(false) }) it('should use custom options when provided', () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 3, circuitBreakerResetTime: 15000, maxFailureEntries: 500, } const lock = new AtomicLock(config, options) // Options are used internally, verified through behavior tests expect(lock).toBeDefined() }) }) describe('tryAcquire', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) }) it('should successfully acquire lock', async () => { mockDriver.tryAcquire.mockResolvedValue(true) const result = await lock.tryAcquire('test-key') expect(result).toBe('mock-uuid-123') expect(mockDriver.tryAcquire).toHaveBeenCalledWith('test-key', 'mock-uuid-123', 10) }) it('should use custom expiry time', async () => { mockDriver.tryAcquire.mockResolvedValue(true) await lock.tryAcquire('test-key', { expiryInSeconds: 30 }) expect(mockDriver.tryAcquire).toHaveBeenCalledWith('test-key', 'mock-uuid-123', 30) }) it('should return null when driver fails to acquire', async () => { mockDriver.tryAcquire.mockResolvedValue(false) const result = await lock.tryAcquire('test-key') expect(result).toBeNull() }) it('should return null when driver throws error', async () => { mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) const result = await lock.tryAcquire('test-key') expect(result).toBeNull() }) it('should return null when circuit breaker is open', async () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 2, circuitBreakerResetTime: 30000, } lock = new AtomicLock(config, options) // Trigger circuit breaker by causing failures mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) await lock.tryAcquire('test-key') await lock.tryAcquire('test-key') // Circuit should be open now const result = await lock.tryAcquire('test-key') expect(result).toBeNull() }) it('should clear failure record on successful acquisition', async () => { // First fail to create a failure record mockDriver.tryAcquire.mockRejectedValueOnce(new Error('Driver error')) await lock.tryAcquire('test-key') // Then succeed mockDriver.tryAcquire.mockResolvedValue(true) const result = await lock.tryAcquire('test-key') expect(result).toBe('mock-uuid-123') // Verify failure record was cleared const status = lock.getCircuitBreakerStatus('test-key') expect(status.failureCount).toBe(0) }) }) describe('acquire', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) }) it('should return lock value on successful acquisition', async () => { mockDriver.tryAcquire.mockResolvedValue(true) const result = await lock.acquire('test-key') expect(result).toBe('mock-uuid-123') }) it('should throw error when acquisition fails', async () => { mockDriver.tryAcquire.mockResolvedValue(false) await expect(lock.acquire('test-key')).rejects.toThrow( 'Failed to acquire lock for key: test-key' ) }) it('should pass options to tryAcquire', async () => { mockDriver.tryAcquire.mockResolvedValue(true) await lock.acquire('test-key', { expiryInSeconds: 25 }) expect(mockDriver.tryAcquire).toHaveBeenCalledWith('test-key', 'mock-uuid-123', 25) }) }) describe('release', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) }) it('should successfully release lock', async () => { mockDriver.release.mockResolvedValue(true) const result = await lock.release('test-key', 'lock-value') expect(result).toBe(true) expect(mockDriver.release).toHaveBeenCalledWith('test-key', 'lock-value') }) it('should return false and log error when driver throws', async () => { const error = new Error('Release error') mockDriver.release.mockRejectedValue(error) const result = await lock.release('test-key', 'lock-value') expect(result).toBe(false) expect(consoleErrorSpy).toHaveBeenCalledWith('Error releasing lock test-key:', error) }) it('should return driver result when release fails', async () => { mockDriver.release.mockResolvedValue(false) const result = await lock.release('test-key', 'lock-value') expect(result).toBe(false) }) }) describe('withLock', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) }) it('should execute callback and return result', async () => { mockDriver.tryAcquire.mockResolvedValue(true) mockDriver.release.mockResolvedValue(true) const callback = jest.fn().mockResolvedValue('callback-result') const result = await lock.withLock('test-key', callback) expect(result).toBe('callback-result') expect(callback).toHaveBeenCalledWith('mock-uuid-123') expect(mockDriver.release).toHaveBeenCalledWith('test-key', 'mock-uuid-123') }) it('should execute synchronous callback', async () => { mockDriver.tryAcquire.mockResolvedValue(true) mockDriver.release.mockResolvedValue(true) const callback = jest.fn().mockReturnValue('sync-result') const result = await lock.withLock('test-key', callback, {}) expect(result).toBe('sync-result') }) it('should release lock even if callback throws', async () => { mockDriver.tryAcquire.mockResolvedValue(true) mockDriver.release.mockResolvedValue(true) const callback = jest.fn().mockRejectedValue(new Error('Callback error')) await expect(lock.withLock('test-key', callback)).rejects.toThrow('Callback error') expect(mockDriver.release).toHaveBeenCalledWith('test-key', 'mock-uuid-123') }) it('should use default options when none provided', async () => { mockDriver.tryAcquire.mockResolvedValue(true) mockDriver.release.mockResolvedValue(true) const callback = jest.fn().mockResolvedValue('result') await lock.withLock('test-key', callback) expect(mockDriver.tryAcquire).toHaveBeenCalledWith('test-key', 'mock-uuid-123', 10) }) }) describe('Circuit Breaker', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 3, circuitBreakerResetTime: 30000, } lock = new AtomicLock(config, options) }) it('should track failure count', async () => { mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) await lock.tryAcquire('test-key') await lock.tryAcquire('test-key') const status = lock.getCircuitBreakerStatus('test-key') expect(status.failureCount).toBe(2) expect(status.isOpen).toBe(false) }) it('should open circuit after threshold is reached', async () => { mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) await lock.tryAcquire('test-key') await lock.tryAcquire('test-key') await lock.tryAcquire('test-key') const status = lock.getCircuitBreakerStatus('test-key') expect(status.failureCount).toBe(3) expect(status.isOpen).toBe(true) expect(status.nextAttemptAt).toBeDefined() }) it('should reset failure count after reset time', async () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 2, circuitBreakerResetTime: 100, // Short time for testing } lock = new AtomicLock(config, options) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Create failure await lock.tryAcquire('test-key') // Wait for reset time await new Promise((resolve) => setTimeout(resolve, 150)) // Next failure should reset count to 1 await lock.tryAcquire('test-key') const status = lock.getCircuitBreakerStatus('test-key') expect(status.failureCount).toBe(1) }) it('should return stats for non-existent key', () => { const status = lock.getCircuitBreakerStatus('non-existent-key') expect(status.isOpen).toBe(false) expect(status.failureCount).toBe(0) expect(status.lastFailure).toBeUndefined() expect(status.nextAttemptAt).toBeUndefined() }) it('should close circuit after reset time passes', async () => { jest.useFakeTimers() const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 2, circuitBreakerResetTime: 30000, } lock = new AtomicLock(config, options) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Open circuit await lock.tryAcquire('test-key') await lock.tryAcquire('test-key') expect(lock.getCircuitBreakerStatus('test-key').isOpen).toBe(true) // Fast forward past reset time jest.advanceTimersByTime(31000) expect(lock.getCircuitBreakerStatus('test-key').isOpen).toBe(false) jest.useRealTimers() }) }) describe('Failure Record Management', () => { let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { maxFailureEntries: 5, circuitBreakerResetTime: 100, } lock = new AtomicLock(config, options) }) it('should cleanup old failure records', async () => { mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Create failure record await lock.tryAcquire('test-key') expect(lock.getCircuitBreakerStatus('test-key').failureCount).toBe(1) // Wait for cleanup time await new Promise((resolve) => setTimeout(resolve, 150)) // Trigger cleanup by creating failure for another key and advancing time const originalNow = Date.now Date.now = jest.fn(() => originalNow() + 70000) // Advance by more than 60 seconds await lock.tryAcquire('another-key') // Original failure should be cleaned up expect(lock.getCircuitBreakerStatus('test-key').failureCount).toBe(0) Date.now = originalNow }) it('should handle exactly 100 entries in cleanup scenario', async () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { maxFailureEntries: 10 } lock = new AtomicLock(config, options) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Create exactly maxFailureEntries + 1 to trigger cleanup of 100 entries for (let i = 0; i <= 10; i++) { await lock.tryAcquire(`test-key-${i}`) } // Should have cleaned up some entries expect(lock.getCircuitBreakerStatus('test-key-0').failureCount).toBe(0) }) it('should handle cleanup when no entries need cleaning', async () => { const originalNow = Date.now const mockNow = jest.fn() Date.now = mockNow // Set initial time mockNow.mockReturnValue(1000000) const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Create a recent failure await lock.tryAcquire('test-key') // Advance time by more than 60 seconds to trigger cleanup mockNow.mockReturnValue(1000000 + 70000) // But make failure recent enough to not be cleaned mockNow.mockReturnValue(1000000 + 70000) // Trigger cleanup attempt await lock.tryAcquire('another-key') Date.now = originalNow }) }) describe('close', () => { it('should clear failure records', async () => { const config: DriverConfig = { driver: 'memory', memory: {} } const lock = new AtomicLock(config) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) await lock.tryAcquire('test-key') expect(lock.getCircuitBreakerStatus('test-key').failureCount).toBe(1) await lock.close() expect(lock.getCircuitBreakerStatus('test-key').failureCount).toBe(0) }) }) describe('createLock factory function', () => { it('should create AtomicLock instance', () => { const config: DriverConfig = { driver: 'memory', memory: {} } const lock = createLock(config) expect(lock).toBeInstanceOf(AtomicLock) }) it('should pass options to AtomicLock constructor', () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 10 } const lock = createLock(config, options) expect(lock).toBeInstanceOf(AtomicLock) }) it('should work without options', () => { const config: DriverConfig = { driver: 'memory', memory: {} } const lock = createLock(config) expect(lock).toBeInstanceOf(AtomicLock) }) }) describe('UUID Generation Edge Case', () => { it('should handle different UUID values', () => { const { v4 } = require('uuid') v4.mockReturnValueOnce('different-uuid-456') const config: DriverConfig = { driver: 'memory', memory: {} } const lock = new AtomicLock(config) mockDriver.tryAcquire.mockResolvedValue(true) lock.tryAcquire('test-key').then((result) => { expect(result).toBe('different-uuid-456') }) }) }) describe('Private Method Coverage', () => { it('should handle multiple driver instantiation scenarios', () => { // Test that each driver constructor is called correctly const configs = [ { driver: 'redis' as const, redis: { host: 'localhost', port: 6379 } }, { driver: 'file' as const, file: { lockDir: '/custom/dir' } }, { driver: 'sqlite' as const, sqlite: { db: 'mock-db' } }, { driver: 'memory' as const, memory: {} }, ] configs.forEach((config) => { const lock = new AtomicLock(config) expect(lock).toBeDefined() }) }) }) let lock: AtomicLock beforeEach(() => { const config: DriverConfig = { driver: 'memory', memory: {} } lock = new AtomicLock(config) }) it('should handle rapid successive calls with circuit breaker', async () => { const config: DriverConfig = { driver: 'memory', memory: {} } const options: LockOptions = { circuitBreakerThreshold: 2 } lock = new AtomicLock(config, options) mockDriver.tryAcquire.mockRejectedValue(new Error('Driver error')) // Rapid failures const promises = [ lock.tryAcquire('test-key'), lock.tryAcquire('test-key'), lock.tryAcquire('test-key'), ] const results = await Promise.all(promises) expect(results.every((result) => result === null)).toBe(true) }) it('should handle mixed success and failure scenarios', async () => { mockDriver.tryAcquire .mockResolvedValueOnce(true) .mockRejectedValueOnce(new Error('Driver error')) .mockResolvedValueOnce(true) const result1 = await lock.tryAcquire('test-key') const result2 = await lock.tryAcquire('test-key') const result3 = await lock.tryAcquire('test-key') expect(result1).toBe('mock-uuid-123') expect(result2).toBeNull() expect(result3).toBe('mock-uuid-123') }) })