A social knowledge tool for researchers built on ATProto
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});