@@ -10,6 +10,7 @@ import { MS_IN_SEC } from '../../../lib/utils/consts';
1010import TimeMs from '../../../lib/utils/time' ;
1111import * as mongodb from 'mongodb' ;
1212import { patch } from '@n1ru4l/json-patch-plus' ;
13+ import * as crypto from 'crypto' ;
1314
1415jest . 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