A social knowledge tool for researchers built on ATProto
at main 345 lines 12 kB view raw
1import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis'; 2import Redis from 'ioredis'; 3import { RedisLockService } from '../RedisLockService'; 4 5describe('RedisLockService Integration', () => { 6 let redisContainer: StartedRedisContainer; 7 let redis: Redis; 8 let lockService: RedisLockService; 9 10 beforeAll(async () => { 11 // Start Redis container 12 redisContainer = await new RedisContainer('redis:7-alpine') 13 .withExposedPorts(6379) 14 .start(); 15 16 // Create Redis connection 17 const connectionUrl = redisContainer.getConnectionUrl(); 18 redis = new Redis(connectionUrl, { maxRetriesPerRequest: null }); 19 20 // Create lock service 21 lockService = new RedisLockService(redis); 22 }, 60000); // Increase timeout for container startup 23 24 afterAll(async () => { 25 // Clean up 26 if (redis) { 27 await redis.quit(); 28 } 29 if (redisContainer) { 30 await redisContainer.stop(); 31 } 32 }); 33 34 beforeEach(async () => { 35 // Clear Redis data between tests 36 await redis.flushall(); 37 }); 38 39 describe('Basic Lock Operations', () => { 40 it('should acquire and release a lock successfully', async () => { 41 // Arrange 42 const lockKey = 'test-lock-key'; 43 let executionCount = 0; 44 const testFunction = async () => { 45 executionCount++; 46 return 'success'; 47 }; 48 49 // Act 50 const requestLock = lockService.createRequestLock(); 51 const result = await requestLock(lockKey, testFunction); 52 53 // Assert 54 expect(result).toBe('success'); 55 expect(executionCount).toBe(1); 56 57 // Verify lock was released by checking Redis directly 58 const lockPattern = `oauth:lock:*:${lockKey}`; 59 const keys = await redis.keys(lockPattern); 60 expect(keys).toHaveLength(0); 61 }); 62 63 it('should prevent concurrent execution of the same lock key', async () => { 64 // Arrange 65 const lockKey = 'concurrent-test-lock'; 66 let executionOrder: number[] = []; 67 let currentExecution = 0; 68 69 const createTestFunction = (id: number) => async () => { 70 const executionId = ++currentExecution; 71 executionOrder.push(id); 72 73 // Simulate some work 74 await new Promise((resolve) => setTimeout(resolve, 100)); 75 76 return `result-${id}-${executionId}`; 77 }; 78 79 // Act - Start two concurrent operations with same lock key 80 const requestLock = lockService.createRequestLock(); 81 const [result1, result2] = await Promise.all([ 82 requestLock(lockKey, createTestFunction(1)), 83 requestLock(lockKey, createTestFunction(2)), 84 ]); 85 86 // Assert - Both should succeed but execute sequentially 87 expect(result1).toMatch(/^result-1-\d+$/); 88 expect(result2).toMatch(/^result-2-\d+$/); 89 expect(executionOrder).toHaveLength(2); 90 91 // Verify they executed sequentially (not concurrently) 92 expect(currentExecution).toBe(2); 93 }); 94 95 it('should allow concurrent execution with different lock keys', async () => { 96 // Arrange 97 const lockKey1 = 'lock-key-1'; 98 const lockKey2 = 'lock-key-2'; 99 let startTimes: number[] = []; 100 101 const createTestFunction = (id: number) => async () => { 102 startTimes.push(Date.now()); 103 await new Promise((resolve) => setTimeout(resolve, 200)); 104 return `result-${id}`; 105 }; 106 107 // Act - Start concurrent operations with different lock keys 108 const requestLock = lockService.createRequestLock(); 109 const startTime = Date.now(); 110 const [result1, result2] = await Promise.all([ 111 requestLock(lockKey1, createTestFunction(1)), 112 requestLock(lockKey2, createTestFunction(2)), 113 ]); 114 const totalTime = Date.now() - startTime; 115 116 // Assert - Both should succeed and execute concurrently 117 expect(result1).toBe('result-1'); 118 expect(result2).toBe('result-2'); 119 expect(startTimes).toHaveLength(2); 120 121 // Should complete in roughly 200ms (concurrent) rather than 400ms (sequential) 122 expect(totalTime).toBeLessThan(350); 123 124 // Start times should be close together (concurrent execution) 125 const timeDiff = Math.abs(startTimes[1]! - startTimes[0]!); 126 expect(timeDiff).toBeLessThan(50); 127 }); 128 129 it('should handle function that throws an error', async () => { 130 // Arrange 131 const lockKey = 'error-test-lock'; 132 const errorMessage = 'Test error'; 133 const errorFunction = async () => { 134 throw new Error(errorMessage); 135 }; 136 137 // Act & Assert 138 const requestLock = lockService.createRequestLock(); 139 await expect(requestLock(lockKey, errorFunction)).rejects.toThrow( 140 errorMessage, 141 ); 142 143 // Verify lock was released even after error 144 const lockPattern = `oauth:lock:*:${lockKey}`; 145 const keys = await redis.keys(lockPattern); 146 expect(keys).toHaveLength(0); 147 }); 148 149 it('should handle async function that returns a promise', async () => { 150 // Arrange 151 const lockKey = 'async-test-lock'; 152 const asyncFunction = async () => { 153 await new Promise((resolve) => setTimeout(resolve, 50)); 154 return { data: 'async-result', timestamp: Date.now() }; 155 }; 156 157 // Act 158 const requestLock = lockService.createRequestLock(); 159 const result = await requestLock(lockKey, asyncFunction); 160 161 // Assert 162 expect(result).toHaveProperty('data', 'async-result'); 163 expect(result).toHaveProperty('timestamp'); 164 expect(typeof result.timestamp).toBe('number'); 165 }); 166 }); 167 168 describe('Lock Key Isolation', () => { 169 it('should include Fly.io instance ID in lock key when available', async () => { 170 // Arrange 171 const originalAllocId = process.env.FLY_ALLOC_ID; 172 process.env.FLY_ALLOC_ID = 'test-instance-123'; 173 174 const lockKey = 'instance-test-lock'; 175 let lockKeyUsed = ''; 176 177 // Mock redlock to capture the actual lock key used 178 const originalAcquire = lockService['redlock'].acquire; 179 lockService['redlock'].acquire = jest 180 .fn() 181 .mockImplementation(async (keys: string[]) => { 182 lockKeyUsed = keys[0]!; 183 return originalAcquire.call(lockService['redlock'], keys, 30000); 184 }); 185 186 try { 187 // Act 188 const requestLock = lockService.createRequestLock(); 189 await requestLock(lockKey, async () => 'test'); 190 191 // Assert 192 expect(lockKeyUsed).toBe(`oauth:lock:test-instance-123:${lockKey}`); 193 } finally { 194 // Cleanup 195 process.env.FLY_ALLOC_ID = originalAllocId; 196 lockService['redlock'].acquire = originalAcquire; 197 } 198 }); 199 200 it('should use "local" as default instance ID when FLY_ALLOC_ID is not set', async () => { 201 // Arrange 202 const originalAllocId = process.env.FLY_ALLOC_ID; 203 delete process.env.FLY_ALLOC_ID; 204 205 const lockKey = 'local-test-lock'; 206 let lockKeyUsed = ''; 207 208 // Mock redlock to capture the actual lock key used 209 const originalAcquire = lockService['redlock'].acquire; 210 lockService['redlock'].acquire = jest 211 .fn() 212 .mockImplementation(async (keys: string[]) => { 213 lockKeyUsed = keys[0]!; 214 return originalAcquire.call(lockService['redlock'], keys, 30000); 215 }); 216 217 try { 218 // Act 219 const requestLock = lockService.createRequestLock(); 220 await requestLock(lockKey, async () => 'test'); 221 222 // Assert 223 expect(lockKeyUsed).toBe(`oauth:lock:local:${lockKey}`); 224 } finally { 225 // Cleanup 226 process.env.FLY_ALLOC_ID = originalAllocId; 227 lockService['redlock'].acquire = originalAcquire; 228 } 229 }); 230 }); 231 232 describe('Lock Timeout and TTL', () => { 233 it('should automatically release lock after TTL expires', async () => { 234 // Arrange 235 const lockKey = 'ttl-test-lock'; 236 237 // Manually acquire a lock with short TTL to simulate timeout 238 const instanceId = process.env.FLY_ALLOC_ID || 'local'; 239 const fullLockKey = `oauth:lock:${instanceId}:${lockKey}`; 240 241 // Use redlock directly to set a very short TTL (100ms) 242 const shortLock = await lockService['redlock'].acquire( 243 [fullLockKey], 244 100, 245 ); 246 247 // Act - Wait for lock to expire 248 await new Promise((resolve) => setTimeout(resolve, 200)); 249 250 // Try to acquire the same lock - should succeed if previous lock expired 251 const requestLock = lockService.createRequestLock(); 252 const result = await requestLock( 253 lockKey, 254 async () => 'success-after-timeout', 255 ); 256 257 // Assert 258 expect(result).toBe('success-after-timeout'); 259 260 // Cleanup - release the short lock (may already be expired) 261 try { 262 await lockService['redlock'].release(shortLock); 263 } catch { 264 // Ignore errors if lock already expired 265 } 266 }); 267 268 it('should handle high concurrency with retry mechanism', async () => { 269 // Arrange 270 const lockKey = 'high-concurrency-lock'; 271 const concurrentOperations = 5; 272 let completedOperations = 0; 273 274 const testFunction = async () => { 275 await new Promise((resolve) => setTimeout(resolve, 50)); 276 return ++completedOperations; 277 }; 278 279 // Act - Start multiple concurrent operations 280 const requestLock = lockService.createRequestLock(); 281 const promises = Array.from({ length: concurrentOperations }, () => 282 requestLock(lockKey, testFunction), 283 ); 284 285 const results = await Promise.all(promises); 286 287 // Assert - All operations should complete successfully 288 expect(results).toHaveLength(concurrentOperations); 289 expect(completedOperations).toBe(concurrentOperations); 290 291 // Results should be sequential numbers (1, 2, 3, 4, 5) 292 const sortedResults = results.sort((a, b) => a - b); 293 expect(sortedResults).toEqual([1, 2, 3, 4, 5]); 294 }); 295 }); 296 297 describe('Error Handling', () => { 298 it('should handle Redis connection issues gracefully', async () => { 299 // Arrange - Create a new Redis connection that we can close 300 const testRedis = new Redis(redisContainer.getConnectionUrl(), { 301 maxRetriesPerRequest: null, 302 }); 303 const testLockService = new RedisLockService(testRedis); 304 305 // Close the connection to simulate network issues 306 await testRedis.quit(); 307 308 // Act & Assert - Should throw an error when trying to acquire lock 309 const requestLock = testLockService.createRequestLock(); 310 await expect( 311 requestLock('test-key', async () => 'should-not-execute'), 312 ).rejects.toThrow(); 313 }); 314 315 it('should release lock even when function execution is interrupted', async () => { 316 // Arrange 317 const lockKey = 'interrupt-test-lock'; 318 let lockAcquired = false; 319 320 const interruptedFunction = async () => { 321 lockAcquired = true; 322 // Simulate an interruption/error after lock is acquired 323 throw new Error('Simulated interruption'); 324 }; 325 326 // Act & Assert 327 const requestLock = lockService.createRequestLock(); 328 await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow( 329 'Simulated interruption', 330 ); 331 332 // Verify lock was acquired initially 333 expect(lockAcquired).toBe(true); 334 335 // Verify lock was released after error 336 const lockPattern = `oauth:lock:*:${lockKey}`; 337 const keys = await redis.keys(lockPattern); 338 expect(keys).toHaveLength(0); 339 340 // Verify we can acquire the same lock again 341 const result = await requestLock(lockKey, async () => 'recovered'); 342 expect(result).toBe('recovered'); 343 }); 344 }); 345});