Skip to content

Commit d416912

Browse files
CopilotneSpecc
andcommitted
Add regression test for stale cached-null + duplicate-key scenario
Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com>
1 parent dde7f14 commit d416912

File tree

1 file changed

+71
-0
lines changed

1 file changed

+71
-0
lines changed

workers/grouper/tests/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MS_IN_SEC } from '../../../lib/utils/consts';
1010
import TimeMs from '../../../lib/utils/time';
1111
import * as mongodb from 'mongodb';
1212
import { patch } from '@n1ru4l/json-patch-plus';
13+
import * as crypto from 'crypto';
1314

1415
jest.mock('amqplib');
1516

@@ -331,6 +332,76 @@ describe('GrouperWorker', () => {
331332

332333
expect((await eventsCollection.findOne({})).payload.context).toBe(null);
333334
});
335+
336+
test('should invalidate stale null cache and process event as repetition when duplicate-key error occurs', async () => {
337+
/**
338+
* This is a regression test for the race condition where:
339+
* 1. Worker A calls getEvent → DB miss → caches null
340+
* 2. Worker B inserts the same event first
341+
* 3. Worker A tries to insertOne → duplicate-key error (E11000)
342+
* 4. Worker A must invalidate the stale null and retry;
343+
* without the cache.del call the retry would see null again → infinite recursion
344+
*/
345+
const RealCacheController = jest.requireActual('../../../lib/cache/controller').default;
346+
const realCache = new RealCacheController();
347+
348+
(worker as any).cache = realCache;
349+
350+
try {
351+
const task = generateTask({ title: 'Stale cache duplicate key regression test' });
352+
353+
/**
354+
* Pre-compute the group hash to know which cache key to poison with null
355+
*/
356+
const groupHash = crypto
357+
.createHmac('sha256', process.env.EVENT_SECRET)
358+
.update(task.catcherType + task.payload.title)
359+
.digest('hex');
360+
361+
const eventCacheKey = `${task.projectId}:${JSON.stringify({ groupHash })}`;
362+
363+
/**
364+
* Pre-insert the event as if Worker B already wrote it to the database
365+
*/
366+
await eventsCollection.insertOne({
367+
groupHash,
368+
totalCount: 1,
369+
catcherType: task.catcherType,
370+
payload: task.payload,
371+
timestamp: task.timestamp,
372+
usersAffected: 0,
373+
});
374+
375+
/**
376+
* Poison the cache with null — simulating Worker A having cached "not found"
377+
* before Worker B's insert completed
378+
*/
379+
realCache.set(eventCacheKey, null);
380+
381+
const delSpy = jest.spyOn(realCache, 'del');
382+
383+
await worker.handle(task);
384+
385+
/**
386+
* cache.del must have been called with the event cache key to evict the stale null
387+
*/
388+
expect(delSpy).toHaveBeenCalledWith(eventCacheKey);
389+
390+
/**
391+
* The retry must have found the pre-inserted event and incremented its counter
392+
*/
393+
const savedEvent = await eventsCollection.findOne({ groupHash });
394+
395+
expect(savedEvent.totalCount).toBe(2);
396+
397+
delSpy.mockRestore();
398+
} finally {
399+
/**
400+
* Restore the globally mocked CacheController so subsequent tests are unaffected
401+
*/
402+
(worker as any).prepareCache();
403+
}
404+
});
334405
});
335406

336407
describe('Saving daily events', () => {

0 commit comments

Comments
 (0)