tangled
alpha
login
or
join now
cosmik.network
/
semble
43
fork
atom
A social knowledge tool for researchers built on ATProto
43
fork
atom
overview
issues
13
pulls
pipelines
formatting and linting
Wesley Finck
4 months ago
d86c1ea9
0f29ff8b
+55
-40
3 changed files
expand all
collapse all
unified
split
eslint.config.mjs
src
shared
infrastructure
locking
RedisLockService.ts
tests
RedisLockService.integration.test.ts
+1
eslint.config.mjs
···
76
NodeJS: 'readonly',
77
clearTimeout: 'readonly',
78
setImmediate: 'readonly',
0
79
},
80
},
81
rules: {
···
76
NodeJS: 'readonly',
77
clearTimeout: 'readonly',
78
setImmediate: 'readonly',
79
+
setInterval: 'readonly',
80
},
81
},
82
rules: {
+1
-1
src/shared/infrastructure/locking/RedisLockService.ts
···
27
// Include Fly.io instance info in lock key
28
const instanceId = process.env.FLY_ALLOC_ID || 'local';
29
const lockKey = `oauth:lock:${instanceId}:${key}`;
30
-
31
// 30 seconds for Fly.io (containers restart more frequently)
32
const lock = await this.redlock.acquire([lockKey], 30000);
33
···
27
// Include Fly.io instance info in lock key
28
const instanceId = process.env.FLY_ALLOC_ID || 'local';
29
const lockKey = `oauth:lock:${instanceId}:${key}`;
30
+
31
// 30 seconds for Fly.io (containers restart more frequently)
32
const lock = await this.redlock.acquire([lockKey], 30000);
33
+53
-39
src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts
···
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
···
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
});
···
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
···
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);
···
136
137
// Act & Assert
138
const requestLock = lockService.createRequestLock();
139
-
await expect(requestLock(lockKey, errorFunction)).rejects.toThrow(errorMessage);
0
0
140
141
// Verify lock was released even after error
142
const lockPattern = `oauth:lock:*:${lockKey}`;
···
148
// Arrange
149
const lockKey = 'async-test-lock';
150
const asyncFunction = async () => {
151
-
await new Promise(resolve => setTimeout(resolve, 50));
152
return { data: 'async-result', timestamp: Date.now() };
153
};
154
···
168
// Arrange
169
const originalAllocId = process.env.FLY_ALLOC_ID;
170
process.env.FLY_ALLOC_ID = 'test-instance-123';
171
-
172
const lockKey = 'instance-test-lock';
173
let lockKeyUsed = '';
174
-
175
// Mock redlock to capture the actual lock key used
176
const originalAcquire = lockService['redlock'].acquire;
177
-
lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => {
178
-
lockKeyUsed = keys[0]!;
179
-
return originalAcquire.call(lockService['redlock'], keys, 30000);
180
-
});
0
0
181
182
try {
183
// Act
···
197
// Arrange
198
const originalAllocId = process.env.FLY_ALLOC_ID;
199
delete process.env.FLY_ALLOC_ID;
200
-
201
const lockKey = 'local-test-lock';
202
let lockKeyUsed = '';
203
-
204
// Mock redlock to capture the actual lock key used
205
const originalAcquire = lockService['redlock'].acquire;
206
-
lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => {
207
-
lockKeyUsed = keys[0]!;
208
-
return originalAcquire.call(lockService['redlock'], keys, 30000);
209
-
});
0
0
210
211
try {
212
// Act
···
227
it('should automatically release lock after TTL expires', async () => {
228
// Arrange
229
const lockKey = 'ttl-test-lock';
230
-
231
// Manually acquire a lock with short TTL to simulate timeout
232
const instanceId = process.env.FLY_ALLOC_ID || 'local';
233
const fullLockKey = `oauth:lock:${instanceId}:${lockKey}`;
234
-
235
// Use redlock directly to set a very short TTL (100ms)
236
-
const shortLock = await lockService['redlock'].acquire([fullLockKey], 100);
0
0
0
237
238
// Act - Wait for lock to expire
239
-
await new Promise(resolve => setTimeout(resolve, 200));
240
241
// Try to acquire the same lock - should succeed if previous lock expired
242
const requestLock = lockService.createRequestLock();
243
-
const result = await requestLock(lockKey, async () => 'success-after-timeout');
0
0
0
244
245
// Assert
246
expect(result).toBe('success-after-timeout');
···
258
const lockKey = 'high-concurrency-lock';
259
const concurrentOperations = 5;
260
let completedOperations = 0;
261
-
262
const testFunction = async () => {
263
-
await new Promise(resolve => setTimeout(resolve, 50));
264
return ++completedOperations;
265
};
266
267
// Act - Start multiple concurrent operations
268
const requestLock = lockService.createRequestLock();
269
const promises = Array.from({ length: concurrentOperations }, () =>
270
-
requestLock(lockKey, testFunction)
271
);
272
-
273
const results = await Promise.all(promises);
274
275
// Assert - All operations should complete successfully
276
expect(results).toHaveLength(concurrentOperations);
277
expect(completedOperations).toBe(concurrentOperations);
278
-
279
// Results should be sequential numbers (1, 2, 3, 4, 5)
280
const sortedResults = results.sort((a, b) => a - b);
281
expect(sortedResults).toEqual([1, 2, 3, 4, 5]);
···
285
describe('Error Handling', () => {
286
it('should handle Redis connection issues gracefully', async () => {
287
// Arrange - Create a new Redis connection that we can close
288
-
const testRedis = new Redis(redisContainer.getConnectionUrl(), {
289
-
maxRetriesPerRequest: null
290
});
291
const testLockService = new RedisLockService(testRedis);
292
-
293
// Close the connection to simulate network issues
294
await testRedis.quit();
295
296
// Act & Assert - Should throw an error when trying to acquire lock
297
const requestLock = testLockService.createRequestLock();
298
await expect(
299
-
requestLock('test-key', async () => 'should-not-execute')
300
).rejects.toThrow();
301
});
302
···
304
// Arrange
305
const lockKey = 'interrupt-test-lock';
306
let lockAcquired = false;
307
-
308
const interruptedFunction = async () => {
309
lockAcquired = true;
310
// Simulate an interruption/error after lock is acquired
···
313
314
// Act & Assert
315
const requestLock = lockService.createRequestLock();
316
-
await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow('Simulated interruption');
317
-
0
0
318
// Verify lock was acquired initially
319
expect(lockAcquired).toBe(true);
320
···
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
···
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
});
···
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
···
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);
···
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}`;
···
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
···
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
···
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
···
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');
···
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]);
···
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
···
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
···
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