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
76
NodeJS: 'readonly',
77
77
clearTimeout: 'readonly',
78
78
setImmediate: 'readonly',
79
79
+
setInterval: 'readonly',
79
80
},
80
81
},
81
82
rules: {
+1
-1
src/shared/infrastructure/locking/RedisLockService.ts
···
27
27
// Include Fly.io instance info in lock key
28
28
const instanceId = process.env.FLY_ALLOC_ID || 'local';
29
29
const lockKey = `oauth:lock:${instanceId}:${key}`;
30
30
-
30
30
+
31
31
// 30 seconds for Fly.io (containers restart more frequently)
32
32
const lock = await this.redlock.acquire([lockKey], 30000);
33
33
+53
-39
src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts
···
69
69
const createTestFunction = (id: number) => async () => {
70
70
const executionId = ++currentExecution;
71
71
executionOrder.push(id);
72
72
-
72
72
+
73
73
// Simulate some work
74
74
-
await new Promise(resolve => setTimeout(resolve, 100));
75
75
-
74
74
+
await new Promise((resolve) => setTimeout(resolve, 100));
75
75
+
76
76
return `result-${id}-${executionId}`;
77
77
};
78
78
···
87
87
expect(result1).toMatch(/^result-1-\d+$/);
88
88
expect(result2).toMatch(/^result-2-\d+$/);
89
89
expect(executionOrder).toHaveLength(2);
90
90
-
90
90
+
91
91
// Verify they executed sequentially (not concurrently)
92
92
expect(currentExecution).toBe(2);
93
93
});
···
97
97
const lockKey1 = 'lock-key-1';
98
98
const lockKey2 = 'lock-key-2';
99
99
let startTimes: number[] = [];
100
100
-
100
100
+
101
101
const createTestFunction = (id: number) => async () => {
102
102
startTimes.push(Date.now());
103
103
-
await new Promise(resolve => setTimeout(resolve, 200));
103
103
+
await new Promise((resolve) => setTimeout(resolve, 200));
104
104
return `result-${id}`;
105
105
};
106
106
···
117
117
expect(result1).toBe('result-1');
118
118
expect(result2).toBe('result-2');
119
119
expect(startTimes).toHaveLength(2);
120
120
-
120
120
+
121
121
// Should complete in roughly 200ms (concurrent) rather than 400ms (sequential)
122
122
expect(totalTime).toBeLessThan(350);
123
123
-
123
123
+
124
124
// Start times should be close together (concurrent execution)
125
125
const timeDiff = Math.abs(startTimes[1]! - startTimes[0]!);
126
126
expect(timeDiff).toBeLessThan(50);
···
136
136
137
137
// Act & Assert
138
138
const requestLock = lockService.createRequestLock();
139
139
-
await expect(requestLock(lockKey, errorFunction)).rejects.toThrow(errorMessage);
139
139
+
await expect(requestLock(lockKey, errorFunction)).rejects.toThrow(
140
140
+
errorMessage,
141
141
+
);
140
142
141
143
// Verify lock was released even after error
142
144
const lockPattern = `oauth:lock:*:${lockKey}`;
···
148
150
// Arrange
149
151
const lockKey = 'async-test-lock';
150
152
const asyncFunction = async () => {
151
151
-
await new Promise(resolve => setTimeout(resolve, 50));
153
153
+
await new Promise((resolve) => setTimeout(resolve, 50));
152
154
return { data: 'async-result', timestamp: Date.now() };
153
155
};
154
156
···
168
170
// Arrange
169
171
const originalAllocId = process.env.FLY_ALLOC_ID;
170
172
process.env.FLY_ALLOC_ID = 'test-instance-123';
171
171
-
173
173
+
172
174
const lockKey = 'instance-test-lock';
173
175
let lockKeyUsed = '';
174
174
-
176
176
+
175
177
// Mock redlock to capture the actual lock key used
176
178
const originalAcquire = lockService['redlock'].acquire;
177
177
-
lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => {
178
178
-
lockKeyUsed = keys[0]!;
179
179
-
return originalAcquire.call(lockService['redlock'], keys, 30000);
180
180
-
});
179
179
+
lockService['redlock'].acquire = jest
180
180
+
.fn()
181
181
+
.mockImplementation(async (keys: string[]) => {
182
182
+
lockKeyUsed = keys[0]!;
183
183
+
return originalAcquire.call(lockService['redlock'], keys, 30000);
184
184
+
});
181
185
182
186
try {
183
187
// Act
···
197
201
// Arrange
198
202
const originalAllocId = process.env.FLY_ALLOC_ID;
199
203
delete process.env.FLY_ALLOC_ID;
200
200
-
204
204
+
201
205
const lockKey = 'local-test-lock';
202
206
let lockKeyUsed = '';
203
203
-
207
207
+
204
208
// Mock redlock to capture the actual lock key used
205
209
const originalAcquire = lockService['redlock'].acquire;
206
206
-
lockService['redlock'].acquire = jest.fn().mockImplementation(async (keys: string[]) => {
207
207
-
lockKeyUsed = keys[0]!;
208
208
-
return originalAcquire.call(lockService['redlock'], keys, 30000);
209
209
-
});
210
210
+
lockService['redlock'].acquire = jest
211
211
+
.fn()
212
212
+
.mockImplementation(async (keys: string[]) => {
213
213
+
lockKeyUsed = keys[0]!;
214
214
+
return originalAcquire.call(lockService['redlock'], keys, 30000);
215
215
+
});
210
216
211
217
try {
212
218
// Act
···
227
233
it('should automatically release lock after TTL expires', async () => {
228
234
// Arrange
229
235
const lockKey = 'ttl-test-lock';
230
230
-
236
236
+
231
237
// Manually acquire a lock with short TTL to simulate timeout
232
238
const instanceId = process.env.FLY_ALLOC_ID || 'local';
233
239
const fullLockKey = `oauth:lock:${instanceId}:${lockKey}`;
234
234
-
240
240
+
235
241
// Use redlock directly to set a very short TTL (100ms)
236
236
-
const shortLock = await lockService['redlock'].acquire([fullLockKey], 100);
242
242
+
const shortLock = await lockService['redlock'].acquire(
243
243
+
[fullLockKey],
244
244
+
100,
245
245
+
);
237
246
238
247
// Act - Wait for lock to expire
239
239
-
await new Promise(resolve => setTimeout(resolve, 200));
248
248
+
await new Promise((resolve) => setTimeout(resolve, 200));
240
249
241
250
// Try to acquire the same lock - should succeed if previous lock expired
242
251
const requestLock = lockService.createRequestLock();
243
243
-
const result = await requestLock(lockKey, async () => 'success-after-timeout');
252
252
+
const result = await requestLock(
253
253
+
lockKey,
254
254
+
async () => 'success-after-timeout',
255
255
+
);
244
256
245
257
// Assert
246
258
expect(result).toBe('success-after-timeout');
···
258
270
const lockKey = 'high-concurrency-lock';
259
271
const concurrentOperations = 5;
260
272
let completedOperations = 0;
261
261
-
273
273
+
262
274
const testFunction = async () => {
263
263
-
await new Promise(resolve => setTimeout(resolve, 50));
275
275
+
await new Promise((resolve) => setTimeout(resolve, 50));
264
276
return ++completedOperations;
265
277
};
266
278
267
279
// Act - Start multiple concurrent operations
268
280
const requestLock = lockService.createRequestLock();
269
281
const promises = Array.from({ length: concurrentOperations }, () =>
270
270
-
requestLock(lockKey, testFunction)
282
282
+
requestLock(lockKey, testFunction),
271
283
);
272
272
-
284
284
+
273
285
const results = await Promise.all(promises);
274
286
275
287
// Assert - All operations should complete successfully
276
288
expect(results).toHaveLength(concurrentOperations);
277
289
expect(completedOperations).toBe(concurrentOperations);
278
278
-
290
290
+
279
291
// Results should be sequential numbers (1, 2, 3, 4, 5)
280
292
const sortedResults = results.sort((a, b) => a - b);
281
293
expect(sortedResults).toEqual([1, 2, 3, 4, 5]);
···
285
297
describe('Error Handling', () => {
286
298
it('should handle Redis connection issues gracefully', async () => {
287
299
// Arrange - Create a new Redis connection that we can close
288
288
-
const testRedis = new Redis(redisContainer.getConnectionUrl(), {
289
289
-
maxRetriesPerRequest: null
300
300
+
const testRedis = new Redis(redisContainer.getConnectionUrl(), {
301
301
+
maxRetriesPerRequest: null,
290
302
});
291
303
const testLockService = new RedisLockService(testRedis);
292
292
-
304
304
+
293
305
// Close the connection to simulate network issues
294
306
await testRedis.quit();
295
307
296
308
// Act & Assert - Should throw an error when trying to acquire lock
297
309
const requestLock = testLockService.createRequestLock();
298
310
await expect(
299
299
-
requestLock('test-key', async () => 'should-not-execute')
311
311
+
requestLock('test-key', async () => 'should-not-execute'),
300
312
).rejects.toThrow();
301
313
});
302
314
···
304
316
// Arrange
305
317
const lockKey = 'interrupt-test-lock';
306
318
let lockAcquired = false;
307
307
-
319
319
+
308
320
const interruptedFunction = async () => {
309
321
lockAcquired = true;
310
322
// Simulate an interruption/error after lock is acquired
···
313
325
314
326
// Act & Assert
315
327
const requestLock = lockService.createRequestLock();
316
316
-
await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow('Simulated interruption');
317
317
-
328
328
+
await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow(
329
329
+
'Simulated interruption',
330
330
+
);
331
331
+
318
332
// Verify lock was acquired initially
319
333
expect(lockAcquired).toBe(true);
320
334