@shagital/atomic-lock
Version:
Universal atomic locking with pluggable drivers (Redis, SQLite, File, Memory)
651 lines (482 loc) • 21.3 kB
text/typescript
import { MemoryLockDriver } from '../src/drivers/memory-driver'
describe('MemoryLockDriver', () => {
let driver: MemoryLockDriver
let mockSetInterval: jest.SpyInstance
let mockClearInterval: jest.SpyInstance
let consoleErrorSpy: jest.SpyInstance
beforeEach(() => {
jest.clearAllMocks()
// Mock timer functions
mockSetInterval = jest.spyOn(global, 'setInterval')
mockClearInterval = jest.spyOn(global, 'clearInterval')
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
})
afterEach(() => {
mockSetInterval.mockRestore()
mockClearInterval.mockRestore()
consoleErrorSpy.mockRestore()
})
describe('Constructor', () => {
it('should create driver with default config', () => {
driver = new MemoryLockDriver()
expect(driver).toBeDefined()
expect(driver.getLockCount()).toBe(0)
expect(mockSetInterval).not.toHaveBeenCalled()
})
it('should create driver with empty config object', () => {
driver = new MemoryLockDriver({})
expect(driver).toBeDefined()
expect(driver.getLockCount()).toBe(0)
expect(mockSetInterval).not.toHaveBeenCalled()
})
it('should create driver with cleanup interval', () => {
const mockTimerId = 'mock-timer-id' as any
mockSetInterval.mockReturnValue(mockTimerId)
driver = new MemoryLockDriver({ cleanupInterval: 30000 })
expect(driver).toBeDefined()
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 30000)
})
it('should not setup cleanup interval when cleanupInterval is 0', () => {
driver = new MemoryLockDriver({ cleanupInterval: 0 })
expect(mockSetInterval).not.toHaveBeenCalled()
})
})
describe('tryAcquire', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should successfully acquire lock when key does not exist', async () => {
const result = await driver.tryAcquire('test-key', 'lock-value-123', 30)
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(1)
})
it('should return false when lock already exists and is not expired', async () => {
// First acquisition
await driver.tryAcquire('test-key', 'first-value', 30)
// Second acquisition should fail
const result = await driver.tryAcquire('test-key', 'second-value', 30)
expect(result).toBe(false)
expect(driver.getLockCount()).toBe(1)
})
it('should acquire lock when existing lock is expired', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
// First acquisition
await driver.tryAcquire('test-key', 'first-value', 1) // 1 second expiry
// Advance time past expiry
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000) // 2 seconds later
// Second acquisition should succeed
const result = await driver.tryAcquire('test-key', 'second-value', 30)
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(1)
// Verify the lock has the new value
const lockInfo = await driver.getLockInfo('test-key')
expect(lockInfo?.value).toBe('second-value')
})
it('should set correct expiry and creation times', async () => {
const mockNow = 1234567890000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('test-key', 'lock-value', 10)
const lockInfo = await driver.getLockInfo('test-key')
expect(lockInfo?.expiresAt).toBe(mockNow + 10000)
expect(lockInfo?.createdAt).toBe(mockNow)
})
})
describe('tryAcquireMultiple', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should successfully acquire multiple locks when all keys are free', async () => {
const result = await driver.tryAcquireMultiple(
['key1', 'key2', 'key3'],
'multi-lock-value',
15
)
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(3)
// Verify all locks have the same value
const lock1 = await driver.getLockInfo('key1')
const lock2 = await driver.getLockInfo('key2')
const lock3 = await driver.getLockInfo('key3')
expect(lock1?.value).toBe('multi-lock-value')
expect(lock2?.value).toBe('multi-lock-value')
expect(lock3?.value).toBe('multi-lock-value')
})
it('should return false when any key already has an active lock', async () => {
// Pre-acquire one key
await driver.tryAcquire('key2', 'existing-value', 30)
const result = await driver.tryAcquireMultiple(
['key1', 'key2', 'key3'],
'multi-lock-value',
15
)
expect(result).toBe(false)
expect(driver.getLockCount()).toBe(1) // Only the pre-existing lock
// Verify no new locks were created
expect(await driver.exists('key1')).toBe(false)
expect(await driver.exists('key3')).toBe(false)
})
it('should clean up expired locks before checking availability', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
// Create expired locks
await driver.tryAcquire('key1', 'expired-value1', 1)
await driver.tryAcquire('key2', 'expired-value2', 1)
expect(driver.getLockCount()).toBe(2)
// Advance time past expiry
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
// Try to acquire multiple - should clean up expired and succeed
const result = await driver.tryAcquireMultiple(
['key1', 'key2', 'key3'],
'new-multi-value',
15
)
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(3)
// Verify all locks have new values
const lock1 = await driver.getLockInfo('key1')
expect(lock1?.value).toBe('new-multi-value')
})
it('should handle mixed expired and active locks', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
// Create one expired lock and one active lock
await driver.tryAcquire('key1', 'expired-value', 1) // Will expire
await driver.tryAcquire('key2', 'active-value', 3000) // Will remain active
// Advance time to expire first lock but not second
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
const result = await driver.tryAcquireMultiple(
['key1', 'key2', 'key3'],
'new-multi-value',
15
)
expect(result).toBe(false) // Should fail due to active key2
expect(driver.getLockCount()).toBe(1) // Only key2 should remain
// Verify key1 was cleaned up but key2 remains
expect(await driver.exists('key1')).toBe(false)
expect(await driver.exists('key2')).toBe(true)
})
it('should handle empty keys array', async () => {
const result = await driver.tryAcquireMultiple([], 'value', 10)
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(0)
})
it('should set same timestamp for all locks in multiple acquisition', async () => {
const mockNow = 1234567890000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquireMultiple(['key1', 'key2'], 'multi-value', 10)
const lock1 = await driver.getLockInfo('key1')
const lock2 = await driver.getLockInfo('key2')
expect(lock1?.createdAt).toBe(mockNow)
expect(lock2?.createdAt).toBe(mockNow)
expect(lock1?.expiresAt).toBe(mockNow + 10000)
expect(lock2?.expiresAt).toBe(mockNow + 10000)
})
})
describe('release', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should successfully release lock with correct value', async () => {
await driver.tryAcquire('test-key', 'correct-value', 30)
const result = await driver.release('test-key', 'correct-value')
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(0)
expect(await driver.exists('test-key')).toBe(false)
})
it('should return false when lock value does not match', async () => {
await driver.tryAcquire('test-key', 'correct-value', 30)
const result = await driver.release('test-key', 'wrong-value')
expect(result).toBe(false)
expect(driver.getLockCount()).toBe(1) // Lock should still exist
expect(await driver.exists('test-key')).toBe(true)
})
it('should return false when lock does not exist', async () => {
const result = await driver.release('non-existent-key', 'any-value')
expect(result).toBe(false)
expect(driver.getLockCount()).toBe(0)
})
it('should return false when trying to release expired lock', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('test-key', 'value', 1)
// Advance time past expiry
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
const result = await driver.release('test-key', 'value')
expect(result).toBe(true)
expect(driver.getLockCount()).toBe(0)
})
})
describe('releaseMultiple', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should release all locks with matching values', async () => {
await driver.tryAcquireMultiple(['key1', 'key2', 'key3'], 'multi-value', 30)
const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'multi-value')
expect(result).toBe(3)
expect(driver.getLockCount()).toBe(0)
})
it('should return count of successfully released locks', async () => {
await driver.tryAcquire('key1', 'correct-value', 30)
await driver.tryAcquire('key2', 'wrong-value', 30)
// key3 doesn't exist
const result = await driver.releaseMultiple(['key1', 'key2', 'key3'], 'correct-value')
expect(result).toBe(1) // Only key1 was released
expect(driver.getLockCount()).toBe(1) // key2 still exists
expect(await driver.exists('key2')).toBe(true)
})
it('should handle empty keys array', async () => {
const result = await driver.releaseMultiple([], 'any-value')
expect(result).toBe(0)
})
it('should handle mix of existing, non-existing, and wrong-value locks', async () => {
await driver.tryAcquire('key1', 'correct-value', 30)
await driver.tryAcquire('key2', 'wrong-value', 30)
await driver.tryAcquire('key4', 'correct-value', 30)
// key3 doesn't exist
const result = await driver.releaseMultiple(['key1', 'key2', 'key3', 'key4'], 'correct-value')
expect(result).toBe(2) // key1 and key4 were released
expect(driver.getLockCount()).toBe(1) // Only key2 remains
})
})
describe('exists', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should return true for existing non-expired lock', async () => {
await driver.tryAcquire('test-key', 'value', 30)
const result = await driver.exists('test-key')
expect(result).toBe(true)
})
it('should return false for non-existent lock', async () => {
const result = await driver.exists('non-existent-key')
expect(result).toBe(false)
})
it('should return false and clean up expired lock', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('test-key', 'value', 1)
expect(driver.getLockCount()).toBe(1)
// Advance time past expiry
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
const result = await driver.exists('test-key')
expect(result).toBe(false)
expect(driver.getLockCount()).toBe(0) // Lock should be cleaned up
})
})
describe('getLockInfo', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should return lock info for existing lock', async () => {
const mockNow = 1234567890000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('test-key', 'test-value', 15)
const result = await driver.getLockInfo('test-key')
expect(result).toEqual({
key: 'test-key',
value: 'test-value',
expiresAt: mockNow + 15000,
createdAt: mockNow,
})
})
it('should return null for non-existent lock', async () => {
const result = await driver.getLockInfo('non-existent-key')
expect(result).toBeNull()
})
it('should return null and clean up expired lock', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('test-key', 'value', 1)
expect(driver.getLockCount()).toBe(1)
// Advance time past expiry
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
const result = await driver.getLockInfo('test-key')
expect(result).toBeNull()
expect(driver.getLockCount()).toBe(0) // Lock should be cleaned up
})
})
describe('cleanup', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should remove all expired locks', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
// Create locks with different expiry times
await driver.tryAcquire('expired1', 'value1', 1) // Will expire in 1 second
await driver.tryAcquire('expired2', 'value2', 2) // Will expire in 2 seconds
await driver.tryAcquire('active', 'value3', 3600) // Will not expire
expect(driver.getLockCount()).toBe(3)
// Advance time to expire first two locks
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 3000)
await driver.cleanup()
expect(driver.getLockCount()).toBe(1)
expect(await driver.exists('expired1')).toBe(false)
expect(await driver.exists('expired2')).toBe(false)
expect(await driver.exists('active')).toBe(true)
})
it('should handle cleanup when no locks exist', async () => {
await driver.cleanup()
expect(driver.getLockCount()).toBe(0)
})
it('should handle cleanup when no locks are expired', async () => {
await driver.tryAcquire('active1', 'value1', 3600)
await driver.tryAcquire('active2', 'value2', 3600)
expect(driver.getLockCount()).toBe(2)
await driver.cleanup()
expect(driver.getLockCount()).toBe(2) // No locks should be removed
})
})
describe('close', () => {
it('should clear cleanup interval and all locks', async () => {
const mockTimerId = 'mock-timer-id' as any
mockSetInterval.mockReturnValue(mockTimerId)
driver = new MemoryLockDriver({ cleanupInterval: 30000 })
// Add some locks
await driver.tryAcquire('key1', 'value1', 30)
await driver.tryAcquire('key2', 'value2', 30)
expect(driver.getLockCount()).toBe(2)
await driver.close()
expect(mockClearInterval).toHaveBeenCalledWith(mockTimerId)
expect(driver.getLockCount()).toBe(0)
})
it('should only clear locks when no cleanup interval exists', async () => {
driver = new MemoryLockDriver()
await driver.tryAcquire('key1', 'value1', 30)
expect(driver.getLockCount()).toBe(1)
await driver.close()
expect(mockClearInterval).not.toHaveBeenCalled()
expect(driver.getLockCount()).toBe(0)
})
})
describe('Testing Helper Methods', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
describe('getLockCount', () => {
it('should return correct count of active locks', async () => {
expect(driver.getLockCount()).toBe(0)
await driver.tryAcquire('key1', 'value1', 30)
expect(driver.getLockCount()).toBe(1)
await driver.tryAcquire('key2', 'value2', 30)
expect(driver.getLockCount()).toBe(2)
await driver.release('key1', 'value1')
expect(driver.getLockCount()).toBe(1)
})
})
describe('getAllLocks', () => {
it('should return all active locks', async () => {
const mockNow = 1234567890000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('key1', 'value1', 10)
await driver.tryAcquire('key2', 'value2', 20)
const allLocks = driver.getAllLocks()
expect(allLocks.size).toBe(2)
expect(allLocks.get('key1')).toEqual({
key: 'key1',
value: 'value1',
expiresAt: mockNow + 10000,
createdAt: mockNow,
})
expect(allLocks.get('key2')).toEqual({
key: 'key2',
value: 'value2',
expiresAt: mockNow + 20000,
createdAt: mockNow,
})
})
it('should exclude expired locks from results', async () => {
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
await driver.tryAcquire('active', 'value1', 3600) // Active
await driver.tryAcquire('expired', 'value2', 1) // Will expire
expect(driver.getLockCount()).toBe(2)
// Advance time to expire second lock
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
const allLocks = driver.getAllLocks()
expect(allLocks.size).toBe(1)
expect(allLocks.has('active')).toBe(true)
expect(allLocks.has('expired')).toBe(false)
})
it('should return empty map when no locks exist', () => {
const allLocks = driver.getAllLocks()
expect(allLocks.size).toBe(0)
expect(allLocks).toBeInstanceOf(Map)
})
})
})
describe('Cleanup Interval Integration', () => {
it('should run cleanup on interval', async () => {
let cleanupCallback: Function
mockSetInterval.mockImplementation((callback) => {
cleanupCallback = callback
return 'timer-id' as any
})
driver = new MemoryLockDriver({ cleanupInterval: 30000 })
const mockNow = 1000000
jest.spyOn(Date, 'now').mockReturnValue(mockNow)
// Create expired and active locks
await driver.tryAcquire('expired', 'value1', 1)
await driver.tryAcquire('active', 'value2', 3600)
expect(driver.getLockCount()).toBe(2)
// Advance time to expire first lock
jest.spyOn(Date, 'now').mockReturnValue(mockNow + 2000)
// Execute the cleanup callback
await cleanupCallback!()
expect(driver.getLockCount()).toBe(1)
expect(await driver.exists('expired')).toBe(false)
expect(await driver.exists('active')).toBe(true)
})
it('should handle cleanup errors in interval', async () => {
let cleanupCallback: Function
mockSetInterval.mockImplementation((callback) => {
cleanupCallback = callback
return 'timer-id' as any
})
driver = new MemoryLockDriver({ cleanupInterval: 30000 })
// Mock cleanup to throw error
const originalCleanup = driver.cleanup
jest.spyOn(driver, 'cleanup').mockRejectedValue(new Error('Cleanup error'))
// Execute the cleanup callback - should not throw
await cleanupCallback!()
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error))
// Restore original cleanup
driver.cleanup = originalCleanup
})
})
describe('Edge Cases', () => {
beforeEach(() => {
driver = new MemoryLockDriver()
})
it('should handle concurrent operations correctly', async () => {
// Simulate concurrent tryAcquire calls
const promises = [
driver.tryAcquire('concurrent-key', 'value1', 30),
driver.tryAcquire('concurrent-key', 'value2', 30),
driver.tryAcquire('concurrent-key', 'value3', 30),
]
const results = await Promise.all(promises)
// Only one should succeed
const successCount = results.filter((r) => r === true).length
expect(successCount).toBe(1)
expect(driver.getLockCount()).toBe(1)
})
it('should handle zero expiry time', async () => {
const result = await driver.tryAcquire('zero-expiry', 'value', 0)
expect(result).toBe(true)
// Lock exists but is immediately expired (implementation allows creation)
const exists = await driver.exists('zero-expiry')
expect(exists).toBe(true) // Changed from false to true
expect(driver.getLockCount()).toBe(1) // Changed from 0 to 1
})
it('should handle negative expiry time', async () => {
const result = await driver.tryAcquire('negative-expiry', 'value', -10)
expect(result).toBe(true)
// Lock should be immediately expired
const exists = await driver.exists('negative-expiry')
expect(exists).toBe(false)
})
})
})