@@ -250,4 +250,192 @@ describe("Worker", () => {
250
250
await redisClient . quit ( ) ;
251
251
}
252
252
) ;
253
+
254
+ redisTest (
255
+ "Should process a job with the same ID only once when rescheduled" ,
256
+ { timeout : 30_000 } ,
257
+ async ( { redisContainer } ) => {
258
+ const processedPayloads : string [ ] = [ ] ;
259
+
260
+ const worker = new Worker ( {
261
+ name : "test-worker" ,
262
+ redisOptions : {
263
+ host : redisContainer . getHost ( ) ,
264
+ port : redisContainer . getPort ( ) ,
265
+ password : redisContainer . getPassword ( ) ,
266
+ } ,
267
+ catalog : {
268
+ testJob : {
269
+ schema : z . object ( { value : z . string ( ) } ) ,
270
+ visibilityTimeoutMs : 5000 ,
271
+ retry : { maxAttempts : 3 } ,
272
+ } ,
273
+ } ,
274
+ jobs : {
275
+ testJob : async ( { payload } ) => {
276
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 30 ) ) ; // Simulate work
277
+ processedPayloads . push ( payload . value ) ;
278
+ } ,
279
+ } ,
280
+ concurrency : {
281
+ workers : 1 ,
282
+ tasksPerWorker : 1 ,
283
+ } ,
284
+ pollIntervalMs : 10 , // Ensure quick polling to detect the scheduled item
285
+ logger : new Logger ( "test" , "log" ) ,
286
+ } ) . start ( ) ;
287
+
288
+ // Unique ID to use for both enqueues
289
+ const testJobId = "duplicate-job-id" ;
290
+
291
+ // Enqueue the first item immediately
292
+ await worker . enqueue ( {
293
+ id : testJobId ,
294
+ job : "testJob" ,
295
+ payload : { value : "first-attempt" } ,
296
+ availableAt : new Date ( Date . now ( ) + 50 ) ,
297
+ } ) ;
298
+
299
+ // Enqueue another item with the same ID but scheduled 50ms in the future
300
+ await worker . enqueue ( {
301
+ id : testJobId ,
302
+ job : "testJob" ,
303
+ payload : { value : "second-attempt" } ,
304
+ availableAt : new Date ( Date . now ( ) + 50 ) ,
305
+ } ) ;
306
+
307
+ // Wait enough time for both jobs to be processed if they were going to be
308
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
309
+
310
+ // Verify that only one job was processed (the second one should have replaced the first)
311
+ expect ( processedPayloads . length ) . toBe ( 1 ) ;
312
+
313
+ // Verify that the second job's payload was the one processed
314
+ expect ( processedPayloads [ 0 ] ) . toBe ( "second-attempt" ) ;
315
+
316
+ await worker . stop ( ) ;
317
+ }
318
+ ) ;
319
+
320
+ redisTest (
321
+ "Should process second job with same ID when enqueued during first job execution with future availableAt" ,
322
+ { timeout : 30_000 } ,
323
+ async ( { redisContainer } ) => {
324
+ const processedPayloads : string [ ] = [ ] ;
325
+ const jobStarted : string [ ] = [ ] ;
326
+ let firstJobCompleted = false ;
327
+ const events : string [ ] = [ ] ;
328
+
329
+ const worker = new Worker ( {
330
+ name : "test-worker" ,
331
+ redisOptions : {
332
+ host : redisContainer . getHost ( ) ,
333
+ port : redisContainer . getPort ( ) ,
334
+ password : redisContainer . getPassword ( ) ,
335
+ } ,
336
+ catalog : {
337
+ testJob : {
338
+ schema : z . object ( { value : z . string ( ) } ) ,
339
+ visibilityTimeoutMs : 5000 ,
340
+ retry : { maxAttempts : 3 } ,
341
+ } ,
342
+ } ,
343
+ jobs : {
344
+ testJob : async ( { payload } ) => {
345
+ // Record when the job starts processing
346
+ jobStarted . push ( payload . value ) ;
347
+ events . push ( `Job started: ${ payload . value } ` ) ;
348
+
349
+ if ( payload . value === "first-attempt" ) {
350
+ // First job takes a long time to process
351
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1_000 ) ) ;
352
+ firstJobCompleted = true ;
353
+ }
354
+
355
+ // Record when the job completes
356
+ processedPayloads . push ( payload . value ) ;
357
+ events . push ( `Job completed: ${ payload . value } ` ) ;
358
+ } ,
359
+ } ,
360
+ concurrency : {
361
+ workers : 1 ,
362
+ tasksPerWorker : 1 ,
363
+ } ,
364
+ pollIntervalMs : 10 ,
365
+ logger : new Logger ( "test" , "log" ) ,
366
+ } ) . start ( ) ;
367
+
368
+ const testJobId = "long-running-job-id" ;
369
+
370
+ // Queue the first job
371
+ await worker . enqueue ( {
372
+ id : testJobId ,
373
+ job : "testJob" ,
374
+ payload : { value : "first-attempt" } ,
375
+ } ) ;
376
+ events . push ( "First job enqueued" ) ;
377
+
378
+ // Verify initial queue size
379
+ const size1 = await worker . queue . size ( { includeFuture : true } ) ;
380
+ events . push ( `Queue size after first enqueue: ${ size1 } ` ) ;
381
+ expect ( size1 ) . toBe ( 1 ) ;
382
+
383
+ // Wait until we know the first job has started processing
384
+ while ( jobStarted . length === 0 ) {
385
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
386
+ }
387
+
388
+ // Now that first job is running, queue second job with same ID
389
+ // Set availableAt to be 1.5 seconds in the future (after first job completes)
390
+ await worker . enqueue ( {
391
+ id : testJobId ,
392
+ job : "testJob" ,
393
+ payload : { value : "second-attempt" } ,
394
+ availableAt : new Date ( Date . now ( ) + 1500 ) ,
395
+ } ) ;
396
+ events . push ( "Second job enqueued with future availableAt" ) ;
397
+
398
+ // Verify queue size after second enqueue
399
+ const size2 = await worker . queue . size ( { includeFuture : true } ) ;
400
+ const size2Present = await worker . queue . size ( { includeFuture : false } ) ;
401
+ events . push ( `Queue size after second enqueue (including future): ${ size2 } ` ) ;
402
+ events . push ( `Queue size after second enqueue (present only): ${ size2Present } ` ) ;
403
+ expect ( size2 ) . toBe ( 1 ) ; // Should still be 1 as it's the same ID
404
+
405
+ // Wait for the first job to complete
406
+ while ( ! firstJobCompleted ) {
407
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
408
+ }
409
+ events . push ( "First job completed" ) ;
410
+
411
+ // Check queue size right after first job completes
412
+ const size3 = await worker . queue . size ( { includeFuture : true } ) ;
413
+ const size3Present = await worker . queue . size ( { includeFuture : false } ) ;
414
+ events . push ( `Queue size after first job completes (including future): ${ size3 } ` ) ;
415
+ events . push ( `Queue size after first job completes (present only): ${ size3Present } ` ) ;
416
+
417
+ // Wait long enough for the second job to become available and potentially run
418
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
419
+
420
+ // Final queue size
421
+ const size4 = await worker . queue . size ( { includeFuture : true } ) ;
422
+ const size4Present = await worker . queue . size ( { includeFuture : false } ) ;
423
+ events . push ( `Final queue size (including future): ${ size4 } ` ) ;
424
+ events . push ( `Final queue size (present only): ${ size4Present } ` ) ;
425
+
426
+ console . log ( "Event sequence:" , events ) ;
427
+
428
+ // First job should have run
429
+ expect ( processedPayloads ) . toContain ( "first-attempt" ) ;
430
+
431
+ // These assertions should fail - demonstrating the bug
432
+ // The second job should run after its availableAt time, but doesn't because
433
+ // the ack from the first job removed it from Redis entirely
434
+ expect ( jobStarted ) . toContain ( "second-attempt" ) ;
435
+ expect ( processedPayloads ) . toContain ( "second-attempt" ) ;
436
+ expect ( processedPayloads . length ) . toBe ( 2 ) ;
437
+
438
+ await worker . stop ( ) ;
439
+ }
440
+ ) ;
253
441
} ) ;
0 commit comments