@@ -300,6 +300,10 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
300300 private redisReadyHandler ?: ( ) => void ;
301301 private runLoopPromise ?: Promise < void > ;
302302
303+ // Track when we started collecting for each group (for in-memory ordering)
304+ // This ensures we wait the full grace period from when we FIRST picked up a job from the group
305+ private groupCollectionStartTimes = new Map < string , number > ( ) ;
306+
303307 constructor ( opts : WorkerOptions < T > ) {
304308 super ( ) ;
305309
@@ -346,6 +350,10 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
346350 this . setupRedisEventHandlers ( ) ;
347351 }
348352
353+ get isClosed ( ) {
354+ return this . closed ;
355+ }
356+
349357 /**
350358 * Add jitter to prevent thundering herd problems in high-concurrency environments
351359 * @param baseInterval The base interval in milliseconds
@@ -918,6 +926,10 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
918926 * @param gracefulTimeoutMs Maximum time to wait for current job to finish (default: 30 seconds)
919927 */
920928 async close ( gracefulTimeoutMs = 30_000 ) : Promise < void > {
929+ // Give some time if we just received a job
930+ // Otherwise jobsInProgress will be 0 and we will exit immediately
931+ await this . delay ( 100 ) ;
932+
921933 this . stopping = true ;
922934
923935 if ( this . cleanupTimer ) {
@@ -1062,12 +1074,18 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
10621074 *
10631075 * How it works:
10641076 * 1. Worker picks up first job and holds it in memory
1065- * 2. Waits for the grace period for new jobs to arrive
1066- * 3. When a new job arrives , reset the timer and hold ALL jobs for another full grace period
1077+ * 2. Waits for the grace period to allow concurrent enqueue operations to finish
1078+ * 3. When new jobs are detected , reset the timer (with decay) and wait again
10671079 * 4. Repeat until no new jobs arrive for the full grace period
10681080 * 5. Process all collected jobs sorted by orderMs
10691081 *
1070- * This ensures jobs arriving close together are batched and processed in correct order.
1082+ * This solves the race condition where jobs are enqueued within 1-2ms:
1083+ * - Worker reserves job 1 while jobs 2-4 are still being written to Redis
1084+ * - By always waiting the grace period, we give concurrent writes time to complete
1085+ * - Then we collect all jobs that finished enqueueing during our wait
1086+ *
1087+ * NOTE: The grace window is measured from when WE picked up the job (Date.now()),
1088+ * NOT from when the job was enqueued. This is critical for handling concurrent enqueues.
10711089 */
10721090 private async collectJobsWithGrace (
10731091 firstJob : ReservedJob < T > ,
@@ -1078,60 +1096,94 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
10781096 return [ firstJob ] ;
10791097 }
10801098
1099+ const groupId = firstJob . groupId ;
10811100 const collected : ReservedJob < T > [ ] = [ firstJob ] ;
1082- const initialCount = await this . q . getGroupJobCount ( firstJob . groupId ) ;
1083- let lastSeenCount = initialCount ;
1084- let lastJobArrivalTime = Date . now ( ) ; // Reset every time a new job arrives
1085- const startTime = Date . now ( ) ;
1101+ const now = Date . now ( ) ;
1102+
1103+ // Check if we're already in a collection window for this group
1104+ let collectionStartTime = this . groupCollectionStartTimes . get ( groupId ) ;
1105+
1106+ if ( ! collectionStartTime ) {
1107+ // First job from this group - start tracking the collection window
1108+ collectionStartTime = now ;
1109+ this . groupCollectionStartTimes . set ( groupId , collectionStartTime ) ;
1110+
1111+ this . logger . debug (
1112+ `Started collection window for group ${ groupId } (${ initialGraceMs } ms grace period)` ,
1113+ ) ;
1114+ } else {
1115+ // We're continuing an existing collection for this group
1116+ // Check if we've already waited long enough
1117+ const elapsedSinceStart = now - collectionStartTime ;
1118+ if ( elapsedSinceStart >= initialGraceMs ) {
1119+ this . logger . debug (
1120+ `Collection window for group ${ groupId } already expired (${ elapsedSinceStart } ms elapsed), processing immediately` ,
1121+ ) ;
1122+ return [ firstJob ] ;
1123+ }
1124+
1125+ this . logger . debug (
1126+ `Continuing collection for group ${ groupId } (${ elapsedSinceStart } ms elapsed)` ,
1127+ ) ;
1128+ }
1129+
1130+ let lastSeenCount = 0 ;
1131+ let lastJobArrivalTime = now ; // Reset every time we detect new jobs
10861132 let currentGraceMs = initialGraceMs ; // Decaying grace window
10871133 const minGraceMs = 20 ; // Minimum wait time
10881134 const decayFactor = this . q . orderingGracePeriodDecay ;
10891135
1090- this . logger . debug (
1091- `Grace period collection started for group ${ firstJob . groupId } (initial count: ${ initialCount } , ${ initialGraceMs } ms window, decay: ${ decayFactor } )` ,
1092- ) ;
1136+ // ALWAYS wait the grace period when we first pick up a job
1137+ // This gives concurrent enqueue operations time to finish writing to Redis
1138+ // The goal is to collect ALL jobs that exist at the END of the grace period
10931139
10941140 // Keep checking for NEW jobs until grace period expires with no new arrivals
10951141 while ( Date . now ( ) - lastJobArrivalTime < currentGraceMs ) {
10961142 await this . delay ( 10 ) ; // Check every 10ms
10971143
1098- const currentCount = await this . q . getGroupJobCount ( firstJob . groupId ) ;
1144+ const currentCount = await this . q . getGroupJobCount ( groupId ) ;
10991145
11001146 if ( currentCount > lastSeenCount ) {
11011147 // New jobs arrived! Reset the grace period timer with decay
11021148 const newJobsCount = currentCount - lastSeenCount ;
11031149 lastSeenCount = currentCount ;
1104- lastJobArrivalTime = Date . now ( ) ; // RESET timer
1150+ lastJobArrivalTime = Date . now ( ) ; // RESET timer to when we detected new jobs
11051151
11061152 // Apply decay: reduce the grace window for the next iteration
11071153 currentGraceMs = Math . max ( minGraceMs , currentGraceMs * decayFactor ) ;
11081154
11091155 this . logger . debug (
1110- `${ newJobsCount } new job(s) detected in group ${ firstJob . groupId } , resetting grace period to ${ Math . round ( currentGraceMs ) } ms (${ Math . round ( ( currentGraceMs / initialGraceMs ) * 100 ) } % of original)` ,
1156+ `${ newJobsCount } new job(s) detected in group ${ groupId } , resetting grace period to ${ Math . round ( currentGraceMs ) } ms (${ Math . round ( ( currentGraceMs / initialGraceMs ) * 100 ) } % of original)` ,
11111157 ) ;
11121158 }
11131159
11141160 // Safety: don't wait forever (max multiplier from queue config)
11151161 const maxWaitMs = initialGraceMs * this . q . orderingMaxWaitMultiplier ;
1116- if ( Date . now ( ) - startTime > maxWaitMs ) {
1162+ const totalWaitTime = Date . now ( ) - collectionStartTime ;
1163+ if ( totalWaitTime > maxWaitMs ) {
11171164 this . logger . warn (
1118- `Grace period exceeded ${ this . q . orderingMaxWaitMultiplier } x limit (${ maxWaitMs } ms) for group ${ firstJob . groupId } , proceeding` ,
1165+ `Grace period exceeded ${ this . q . orderingMaxWaitMultiplier } x limit (${ maxWaitMs } ms) for group ${ groupId } (waited ${ totalWaitTime } ms) , proceeding` ,
11191166 ) ;
11201167 break ;
11211168 }
11221169 }
11231170
1124- // Only collect jobs we detected DURING the grace period
1125- // lastSeenCount is the count at the last detection, which happened within the grace window
1126- const jobsArrivedDuringGrace = Math . max ( 0 , lastSeenCount - initialCount ) ;
1171+ // Collect ALL remaining jobs in the group (up to configured batch limit)
1172+ // These are the jobs that were there when the grace period ended
1173+ const finalCount = await this . q . getGroupJobCount ( groupId ) ;
1174+ const maxBatchSize = this . q . orderingMaxBatchSize ;
1175+ const jobsToCollect = Math . min ( finalCount , maxBatchSize ) ;
1176+
1177+ this . logger . debug (
1178+ `Grace period complete for group ${ groupId } , collecting ${ jobsToCollect } of ${ finalCount } remaining jobs (max batch: ${ maxBatchSize } )` ,
1179+ ) ;
11271180
1128- // Only collect jobs that arrived DURING the grace period
1129- // Don't collect ALL jobs - that would break the FIFO guarantee
1130- for ( let i = 0 ; i < jobsArrivedDuringGrace && i < 20 ; i ++ ) {
1181+ // Collect remaining jobs from the group (up to max batch size)
1182+ for ( let i = 0 ; i < jobsToCollect ; i ++ ) {
11311183 try {
11321184 // Reserve next job from this group, passing first job's ID to bypass lock
11331185 const nextJob = await this . reserveNextFromSameGroup (
1134- firstJob . groupId ,
1186+ groupId ,
11351187 firstJob . id ,
11361188 ) ;
11371189 if ( ! nextJob ) break ;
@@ -1155,7 +1207,7 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
11551207
11561208 if ( collected . length > 1 ) {
11571209 this . logger . info (
1158- `Collected ${ collected . length } jobs from group ${ firstJob . groupId } during ${ Date . now ( ) - startTime } ms grace period` ,
1210+ `Collected ${ collected . length } jobs from group ${ groupId } during ${ Date . now ( ) - now } ms grace period` ,
11591211 {
11601212 unsorted : oldCollected . map ( ( job ) => job . id ) ,
11611213 sorted : collected . map ( ( job , index ) => ( {
@@ -1169,6 +1221,10 @@ class _Worker<T = any> extends TypedEventEmitter<WorkerEvents<T>> {
11691221 ) ;
11701222 }
11711223
1224+ // Clear the tracking for this group after we've collected the batch
1225+ // This allows the next batch to start fresh
1226+ this . groupCollectionStartTimes . delete ( groupId ) ;
1227+
11721228 return collected ;
11731229 }
11741230
0 commit comments