bunqueue 2.8.32 → 2.8.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/dist/application/backgroundTasks.js +17 -9
  3. package/dist/application/dependencyProcessor.js +6 -1
  4. package/dist/application/dlqManager.js +1 -1
  5. package/dist/application/lockManager.js +1 -1
  6. package/dist/application/operations/ack.js +23 -1
  7. package/dist/application/operations/jobManagement.js +2 -2
  8. package/dist/application/operations/jobStateTransitions.js +1 -1
  9. package/dist/application/operations/pull.js +125 -96
  10. package/dist/application/operations/push.js +2 -2
  11. package/dist/application/operations/queryOperations.js +74 -70
  12. package/dist/application/queueManager.d.ts +6 -11
  13. package/dist/application/queueManager.js +16 -3
  14. package/dist/application/queueStatsAggregator.d.ts +23 -0
  15. package/dist/application/queueStatsAggregator.js +80 -0
  16. package/dist/application/statsManager.d.ts +2 -26
  17. package/dist/application/statsManager.js +8 -124
  18. package/dist/domain/queue/shard.d.ts +3 -1
  19. package/dist/domain/queue/shard.js +15 -7
  20. package/dist/domain/queue/temporalIndex.d.ts +24 -0
  21. package/dist/domain/queue/temporalIndex.js +100 -0
  22. package/dist/domain/queue/temporalManager.d.ts +2 -11
  23. package/dist/domain/queue/temporalManager.js +27 -44
  24. package/dist/domain/queue/waiterManager.d.ts +11 -13
  25. package/dist/domain/queue/waiterManager.js +80 -49
  26. package/dist/infrastructure/cloud/commands.js +5 -2
  27. package/dist/infrastructure/cloud/snapshotCollector.js +5 -2
  28. package/dist/infrastructure/cloud/statsRefresh.js +5 -2
  29. package/dist/infrastructure/cloud/statsUpdate.js +5 -2
  30. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  31. package/dist/infrastructure/persistence/schema.js +13 -1
  32. package/dist/infrastructure/persistence/sqlite.d.ts +18 -2
  33. package/dist/infrastructure/persistence/sqlite.js +49 -6
  34. package/dist/infrastructure/server/queueCountsScheduler.d.ts +13 -0
  35. package/dist/infrastructure/server/queueCountsScheduler.js +35 -0
  36. package/dist/infrastructure/server/sseHandler.d.ts +1 -0
  37. package/dist/infrastructure/server/sseHandler.js +18 -13
  38. package/dist/infrastructure/server/wsHandler.d.ts +1 -1
  39. package/dist/infrastructure/server/wsHandler.js +18 -15
  40. package/package.json +3 -3
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <p align="center">
16
16
  High-performance job queue for Bun. Built for AI agents and automation.<br/>
17
- Zero external dependencies. MCP-native. TypeScript-first.
17
+ Zero external infrastructure. MCP-native. TypeScript-first.
18
18
  </p>
19
19
 
20
20
  <p align="center">
@@ -471,7 +471,8 @@ await app.close(true); // force shutdown
471
471
 
472
472
  ```typescript
473
473
  const counts = await queue.getJobCountsAsync();
474
- // { waiting, prioritized, active, completed, failed, delayed, paused }
474
+ // { waiting, prioritized, active, completed, failed, delayed,
475
+ // 'waiting-children', paused }
475
476
 
476
477
  // Failed jobs (those that exhausted their attempts) are enumerable by state —
477
478
  // the failed list reflects the same jobs that `failed` counts.
@@ -661,7 +662,7 @@ bunx bunqueue-dashboard
661
662
 
662
663
  - **MCP server included** — 73 tools, 5 resources, 3 prompts. AI agents get full control out of the box
663
664
  - **BullMQ-compatible API** — Same `Queue`, `Worker`, `QueueEvents`
664
- - **Zero dependencies** — No Redis, no MongoDB
665
+ - **Zero external infrastructure** — No Redis, no MongoDB
665
666
  - **Tiny footprint** — 5.5 MB installed, 7 packages (just 2 runtime deps: `croner` + `msgpackr`)
666
667
  - **SQLite persistence** — Survives restarts, WAL mode for concurrent access
667
668
  - **Up to 630K ops/sec** — [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
@@ -201,9 +201,11 @@ export function recover(ctx) {
201
201
  const now = Date.now();
202
202
  // === PHASE 1: Recover active jobs (were processing when server stopped) ===
203
203
  // These jobs are considered "stalled" and need to be retried or moved to DLQ
204
- let activeOffset = 0;
205
204
  while (true) {
206
- const activeJobs = ctx.storage.loadActiveJobs(RECOVERY_BATCH_SIZE, activeOffset);
205
+ // Every successfully handled row leaves state='active' (retry -> waiting, or
206
+ // delete -> DLQ/discard). Always read the first page: advancing OFFSET over
207
+ // this shrinking result set would skip up to half of the active rows.
208
+ const activeJobs = ctx.storage.loadActiveJobs(RECOVERY_BATCH_SIZE, 0);
207
209
  if (activeJobs.length === 0)
208
210
  break;
209
211
  for (const job of activeJobs) {
@@ -248,22 +250,20 @@ export function recover(ctx) {
248
250
  ctx.storage.deleteJob(job.id);
249
251
  }
250
252
  else {
251
- // Retry: put back in queue with backoff (uses backoffConfig if present)
253
+ // Persist the retry here, but let Phase 2 be the single authoritative
254
+ // enqueue path. Enqueuing in both phases replaces the priority-queue entry
255
+ // by ID but increments queued/delayed counters twice.
252
256
  job.runAt = now + calculateBackoff(job);
253
- shard.getQueue(job.queue).push(job);
254
- const isDelayed = job.runAt > now;
255
- shard.incrementQueued(job.id, isDelayed, job.createdAt, job.queue, job.runAt);
256
- ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: job.queue });
257
257
  ctx.storage.updateForRetry(job);
258
258
  }
259
259
  ctx.registerQueueName(job.queue);
260
260
  }
261
- activeOffset += activeJobs.length;
262
261
  if (activeJobs.length < RECOVERY_BATCH_SIZE)
263
262
  break;
264
263
  }
265
264
  // === PHASE 2: Load pending jobs ===
266
265
  let offset = 0;
266
+ const corruptPendingJobs = [];
267
267
  // Load pending jobs in batches to avoid memory spikes
268
268
  while (true) {
269
269
  const jobs = ctx.storage.loadPendingJobs(RECOVERY_BATCH_SIZE, offset);
@@ -276,7 +276,10 @@ export function recover(ctx) {
276
276
  // Route the job to the DLQ rather than enqueuing it as ready (out-of-order
277
277
  // execution) or parking it in waitingDeps forever (unbounded leak).
278
278
  if (isCorruptDependsOn(job)) {
279
- quarantineCorruptDependsOn(ctx, job);
279
+ // Keep the SQLite pending result set stable until OFFSET pagination has
280
+ // finished. Deleting this row now shifts a healthy row across the next
281
+ // page boundary and leaves it absent from the in-memory queue.
282
+ corruptPendingJobs.push(job);
280
283
  continue;
281
284
  }
282
285
  // Check if job has unmet dependencies
@@ -313,6 +316,11 @@ export function recover(ctx) {
313
316
  if (jobs.length < RECOVERY_BATCH_SIZE)
314
317
  break;
315
318
  }
319
+ // Quarantine only after the paginated scan so deletions cannot move page
320
+ // boundaries. loadDlq() below restores these entries into memory exactly once.
321
+ for (const job of corruptPendingJobs) {
322
+ quarantineCorruptDependsOn(ctx, job);
323
+ }
316
324
  // Load DLQ entries
317
325
  const dlqEntries = ctx.storage.loadDlq();
318
326
  for (const [queue, entries] of dlqEntries) {
@@ -76,7 +76,12 @@ function promoteJobsToQueue(jobsToPromote, shard, ctx, shardIdx) {
76
76
  }
77
77
  }
78
78
  if (jobsToPromote.length > 0) {
79
- shard.notify();
79
+ const promotedByQueue = new Map();
80
+ for (const job of jobsToPromote) {
81
+ promotedByQueue.set(job.queue, (promotedByQueue.get(job.queue) ?? 0) + 1);
82
+ }
83
+ for (const [queue, count] of promotedByQueue)
84
+ shard.notifyBatch(queue, count);
80
85
  for (const job of jobsToPromote) {
81
86
  ctx.dashboardEmit?.('job:dependencies-resolved', { jobId: String(job.id), queue: job.queue });
82
87
  }
@@ -274,6 +274,6 @@ function requeueCompletedJob(job, ctx) {
274
274
  ctx.completedJobs.delete(job.id);
275
275
  ctx.jobResults.delete(job.id);
276
276
  ctx.storage?.updateForRetry(job);
277
- shard.notify();
277
+ shard.notify(job.queue);
278
278
  return 1;
279
279
  }
@@ -144,7 +144,7 @@ function requeueExpiredJob(opts) {
144
144
  const isDelayed = job.runAt > now;
145
145
  shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
146
146
  ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
147
- shard.notify();
147
+ shard.notify(job.queue);
148
148
  ctx.eventsManager.broadcast({
149
149
  eventType: "stalled" /* EventType.Stalled */,
150
150
  jobId,
@@ -26,7 +26,11 @@ export async function ackJob(jobId, result, ctx) {
26
26
  }
27
27
  const idx = shardIndex(job.queue);
28
28
  await withWriteLock(ctx.shardLocks[idx], () => {
29
- ctx.shards[idx].releaseJobResources(job.queue, job.uniqueKey, job.groupId);
29
+ const shard = ctx.shards[idx];
30
+ shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
31
+ // Releasing either queue concurrency or an active FIFO group can make a
32
+ // parked job eligible. Wake only waiters for this queue.
33
+ shard.notify(job.queue);
30
34
  });
31
35
  // Release customId so it can be reused
32
36
  if (job.customId && ctx.customIdMap) {
@@ -179,6 +183,9 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
179
183
  else {
180
184
  moveFailedJobToDlq(job, jobId, error, shard, ctx);
181
185
  }
186
+ // Terminal failure and retry both release queue concurrency (and possibly
187
+ // an active FIFO group), so another job may now be eligible.
188
+ shard.notify(job.queue);
182
189
  });
183
190
  ctx.broadcast({
184
191
  eventType: 'failed',
@@ -234,6 +241,9 @@ export async function ackJobBatch(jobIds, ctx) {
234
241
  // Step 3-4: Group by queue shard and release
235
242
  const byQueueShard = groupByQueueShard(extractedJobs);
236
243
  await releaseResources(byQueueShard, batchCtx);
244
+ for (const [idx, jobs] of byQueueShard) {
245
+ notifyReleasedQueues(ctx.shards[idx], jobs);
246
+ }
237
247
  // Step 5: Finalize
238
248
  finalizeBatchAck(extractedJobs, ctx, false);
239
249
  }
@@ -260,6 +270,18 @@ export async function ackJobBatchWithResults(items, ctx) {
260
270
  // Step 3-4: Group by queue shard and release
261
271
  const byQueueShard = groupByQueueShard(extractedJobs);
262
272
  await releaseResources(byQueueShard, batchCtx);
273
+ for (const [idx, jobs] of byQueueShard) {
274
+ notifyReleasedQueues(ctx.shards[idx], jobs);
275
+ }
263
276
  // Step 5: Finalize with results
264
277
  finalizeBatchAck(extractedJobs, ctx, true);
265
278
  }
279
+ /** Wake queue-scoped waiters for concurrency/group slots released by a batch. */
280
+ function notifyReleasedQueues(shard, jobs) {
281
+ const releasedByQueue = new Map();
282
+ for (const job of jobs) {
283
+ releasedByQueue.set(job.queue, (releasedByQueue.get(job.queue) ?? 0) + 1);
284
+ }
285
+ for (const [queue, count] of releasedByQueue)
286
+ shard.notifyBatch(queue, count);
287
+ }
@@ -212,7 +212,7 @@ export async function moveJobToDelayed(jobId, delay, ctx) {
212
212
  shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
213
213
  ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
214
214
  // The freed slot may unblock waiting jobs for long-pollers.
215
- shard.notify();
215
+ shard.notify(job.queue);
216
216
  });
217
217
  // Persist the move so the delay survives a restart: the on-disk row was
218
218
  // `state='active'` with the old run_at, which recovery would otherwise treat as a
@@ -270,7 +270,7 @@ export async function discardJob(jobId, ctx) {
270
270
  // lockManager.ts), so the DLQ entry never keeps it.
271
271
  shard.releaseJobResources(validJob.queue, validJob.uniqueKey, validJob.groupId);
272
272
  // The freed slot may unblock waiting jobs for long-pollers.
273
- shard.notify();
273
+ shard.notify(validJob.queue);
274
274
  }
275
275
  else if (validJob.uniqueKey) {
276
276
  // Queue branch: the waiting job never acquired a slot or group at
@@ -33,7 +33,7 @@ export async function moveActiveToWait(jobId, ctx) {
33
33
  shard.getQueue(job.queue).push(job);
34
34
  shard.incrementQueued(jobId, false, job.createdAt, job.queue, job.runAt);
35
35
  ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
36
- shard.notify();
36
+ shard.notify(job.queue);
37
37
  });
38
38
  ctx.eventsManager.broadcast({
39
39
  eventType: 'waiting',
@@ -8,65 +8,119 @@ import { shardIndex, processingShardIndex } from '../../shared/hash';
8
8
  import { latencyTracker } from '../latencyTracker';
9
9
  import { throughputTracker } from '../throughputTracker';
10
10
  /**
11
- * Try to dequeue next ready job from queue
12
- * Handles: expired skip, ready check, group blocking, timestamps
11
+ * Dequeue the highest-ranked eligible job without letting a delayed or
12
+ * group-blocked heap root stall unrelated work.
13
+ *
14
+ * The priority heap cannot encode time-varying readiness or active-group
15
+ * membership in its comparator. Temporarily park ineligible roots, select the
16
+ * first eligible entry (therefore the best eligible entry in heap order), and
17
+ * restore every parked job before leaving the shard critical section. Parked
18
+ * jobs remain logically queued throughout: their shard counters and jobIndex
19
+ * entries are deliberately untouched.
20
+ *
21
+ * Complexity is O((k + 1) log n) time and O(k) scratch space, where k is the
22
+ * number of higher-ranked ineligible jobs skipped. A separate delayed heap and
23
+ * per-group eligible-head heap can reduce this to O(log n), but this scan keeps
24
+ * the existing queue representation and state transitions intact.
13
25
  */
14
26
  function tryDequeueNextJob(shard, queue, now, ctx) {
15
27
  const q = shard.getQueue(queue);
16
- const job = q.peek();
17
- if (!job)
18
- return { status: 'stop' };
19
- // Skip expired jobs
20
- if (isExpired(job, now)) {
21
- q.pop();
22
- shard.decrementQueued(job.id);
23
- ctx.jobIndex.delete(job.id);
24
- ctx.dashboardEmit?.('job:expired', {
25
- queue,
26
- jobId: String(job.id),
27
- ttl: job.ttl,
28
- age: now - job.createdAt,
29
- });
30
- return { status: 'skip' };
31
- }
32
- // Not ready yet (delayed)
33
- if (!isReady(job, now))
34
- return { status: 'stop' };
35
- // FIFO group blocked
36
- if (job.groupId && shard.isGroupActive(queue, job.groupId)) {
37
- return { status: 'stop' };
38
- }
39
- // Dequeue and update
40
- q.pop();
41
- shard.decrementQueued(job.id);
42
- if (job.groupId) {
43
- shard.activateGroup(queue, job.groupId);
28
+ const parked = [];
29
+ let nextRunAt = null;
30
+ try {
31
+ while (true) {
32
+ const job = q.peek();
33
+ if (!job)
34
+ return { status: 'stop', nextRunAt };
35
+ // Expired entries are not parked: they are genuinely removed from the
36
+ // logical queue and therefore update counters/index exactly once.
37
+ if (isExpired(job, now)) {
38
+ q.pop();
39
+ shard.decrementQueued(job.id);
40
+ ctx.jobIndex.delete(job.id);
41
+ ctx.dashboardEmit?.('job:expired', {
42
+ queue,
43
+ jobId: String(job.id),
44
+ ttl: job.ttl,
45
+ age: now - job.createdAt,
46
+ });
47
+ continue;
48
+ }
49
+ if (!isReady(job, now)) {
50
+ const delayed = q.pop();
51
+ if (delayed)
52
+ parked.push(delayed);
53
+ nextRunAt = nextRunAt === null ? job.runAt : Math.min(nextRunAt, job.runAt);
54
+ continue;
55
+ }
56
+ if (job.groupId && shard.isGroupActive(queue, job.groupId)) {
57
+ const blocked = q.pop();
58
+ if (blocked)
59
+ parked.push(blocked);
60
+ continue;
61
+ }
62
+ // Check capacity only after proving that an eligible job exists. Check
63
+ // concurrency first because that slot can be returned if rate limiting
64
+ // rejects; rate-limit tokens have no rollback operation.
65
+ if (!shard.tryAcquireConcurrency(queue)) {
66
+ ctx.dashboardEmit?.('concurrency:rejected', { queue });
67
+ return { status: 'stop', nextRunAt };
68
+ }
69
+ if (!shard.tryAcquireRateLimit(queue)) {
70
+ shard.releaseConcurrency(queue);
71
+ ctx.dashboardEmit?.('ratelimit:rejected', { queue });
72
+ return { status: 'stop', nextRunAt };
73
+ }
74
+ // Dequeue and update. q.peek() and q.pop() are synchronous under the
75
+ // shard write lock, so pop returns the same eligible entry selected above.
76
+ const dequeued = q.pop();
77
+ if (!dequeued) {
78
+ shard.releaseConcurrency(queue);
79
+ return { status: 'stop', nextRunAt };
80
+ }
81
+ shard.decrementQueued(dequeued.id);
82
+ if (dequeued.groupId) {
83
+ shard.activateGroup(queue, dequeued.groupId);
84
+ }
85
+ dequeued.startedAt = now;
86
+ dequeued.lastHeartbeat = now;
87
+ if (dequeued.timeline.length < MAX_TIMELINE_ENTRIES) {
88
+ dequeued.timeline.push({ state: 'active', timestamp: now });
89
+ }
90
+ // Make the queue -> processing transition atomic for observers. From the
91
+ // pop above, the job is no longer findable in the shard queue, so a
92
+ // jobIndex entry still saying 'queue' would make getJob/getJobState return
93
+ // a false null/'unknown' for an existing job (JOB -> null -> JOB(active)
94
+ // flicker), and cleanOrphanedJobIndex would even drop its index entry.
95
+ // Insert into processingShards and flip the index HERE, in the same
96
+ // synchronous critical section as the pop (the caller holds the shard
97
+ // write lock; JS is single-threaded, so no other task can interleave).
98
+ // Writing processingShards without its RWLock is safe: until this
99
+ // statement the index said 'queue', so no id-targeted critical section
100
+ // (ack/fail/discard operate only on ids they find as 'processing') can be
101
+ // mid-operation on this id; a sync Map.set cannot corrupt a paused
102
+ // critical section; and lock-free sync access to processingShards is
103
+ // established practice (stallDetection phases 1/2, collectActiveJobs,
104
+ // getJobProgress). Acquiring the async RWLock here would instead hold the
105
+ // hot shard write lock across an await.
106
+ const procIdx = processingShardIndex(dequeued.id);
107
+ ctx.processingShards[procIdx].set(dequeued.id, dequeued);
108
+ ctx.jobIndex.set(dequeued.id, { type: 'processing', shardIdx: procIdx });
109
+ return { status: 'job', job: dequeued };
110
+ }
44
111
  }
45
- job.startedAt = now;
46
- job.lastHeartbeat = now;
47
- if (job.timeline.length < MAX_TIMELINE_ENTRIES) {
48
- job.timeline.push({ state: 'active', timestamp: now });
112
+ finally {
113
+ // Restore only the priority-queue membership. The logical queue counters,
114
+ // temporal index and global jobIndex never changed for parked entries.
115
+ for (const parkedJob of parked)
116
+ q.push(parkedJob);
49
117
  }
50
- // Make the queue -> processing transition atomic for observers. From the
51
- // pop above, the job is no longer findable in the shard queue, so a
52
- // jobIndex entry still saying 'queue' would make getJob/getJobState return
53
- // a false null/'unknown' for an existing job (JOB -> null -> JOB(active)
54
- // flicker), and cleanOrphanedJobIndex would even drop its index entry.
55
- // Insert into processingShards and flip the index HERE, in the same
56
- // synchronous critical section as the pop (the caller holds the shard
57
- // write lock; JS is single-threaded, so no other task can interleave).
58
- // Writing processingShards without its RWLock is safe: until this
59
- // statement the index said 'queue', so no id-targeted critical section
60
- // (ack/fail/discard operate only on ids they find as 'processing') can be
61
- // mid-operation on this id; a sync Map.set cannot corrupt a paused
62
- // critical section; and lock-free sync access to processingShards is
63
- // established practice (stallDetection phases 1/2, collectActiveJobs,
64
- // getJobProgress). Acquiring the async RWLock here would instead hold the
65
- // hot shard write lock across an await.
66
- const procIdx = processingShardIndex(job.id);
67
- ctx.processingShards[procIdx].set(job.id, job);
68
- ctx.jobIndex.set(job.id, { type: 'processing', shardIdx: procIdx });
69
- return { status: 'job', job };
118
+ }
119
+ /** Wait until either a notification arrives, the next delay matures, or the pull deadline. */
120
+ async function waitForNextCandidate(shard, queue, deadline, now, nextRunAt) {
121
+ const remaining = deadline - now;
122
+ const untilNextRun = nextRunAt === null ? remaining : Math.max(1, nextRunAt - now);
123
+ await shard.waitForJob(queue, Math.min(remaining, untilNextRun));
70
124
  }
71
125
  /**
72
126
  * Finish the handoff of a dequeued job: persist active state and broadcast.
@@ -141,7 +195,7 @@ async function requeueJob(job, queue, idx, ctx) {
141
195
  shard.getQueue(queue).push(job);
142
196
  shard.incrementQueued(job.id, false, job.createdAt, queue, job.runAt);
143
197
  ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: queue });
144
- shard.notify();
198
+ shard.notify(queue);
145
199
  requeued = true;
146
200
  });
147
201
  if (!requeued)
@@ -163,7 +217,8 @@ export async function pullJob(queue, timeoutMs, ctx) {
163
217
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
164
218
  const idx = shardIndex(queue);
165
219
  while (true) {
166
- const job = await tryPullFromShard(queue, idx, ctx);
220
+ const attempt = await tryPullFromShard(queue, idx, ctx);
221
+ const job = attempt.job;
167
222
  if (job) {
168
223
  let delivered = false;
169
224
  try {
@@ -186,9 +241,7 @@ export async function pullJob(queue, timeoutMs, ctx) {
186
241
  if (deadline === 0 || now >= deadline) {
187
242
  return null;
188
243
  }
189
- // Wait for notification or timeout
190
- const remaining = deadline - now;
191
- await ctx.shards[idx].waitForJob(remaining);
244
+ await waitForNextCandidate(ctx.shards[idx], queue, deadline, now, attempt.nextRunAt);
192
245
  }
193
246
  }
194
247
  /**
@@ -199,27 +252,13 @@ async function tryPullFromShard(queue, idx, ctx) {
199
252
  const shard = ctx.shards[idx];
200
253
  const state = shard.getState(queue);
201
254
  if (state.paused) {
202
- return null;
203
- }
204
- if (!shard.tryAcquireRateLimit(queue)) {
205
- ctx.dashboardEmit?.('ratelimit:rejected', { queue });
206
- return null;
207
- }
208
- if (!shard.tryAcquireConcurrency(queue)) {
209
- ctx.dashboardEmit?.('concurrency:rejected', { queue });
210
- return null;
255
+ return { job: null, nextRunAt: null };
211
256
  }
212
257
  const now = Date.now();
213
- while (true) {
214
- const result = tryDequeueNextJob(shard, queue, now, ctx);
215
- if (result.status === 'job')
216
- return result.job;
217
- if (result.status === 'stop') {
218
- shard.releaseConcurrency(queue);
219
- return null;
220
- }
221
- // status === 'skip': continue loop
222
- }
258
+ const result = tryDequeueNextJob(shard, queue, now, ctx);
259
+ return result.status === 'job'
260
+ ? { job: result.job, nextRunAt: null }
261
+ : { job: null, nextRunAt: result.nextRunAt };
223
262
  });
224
263
  }
225
264
  /**
@@ -230,7 +269,8 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx) {
230
269
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
231
270
  const idx = shardIndex(queue);
232
271
  while (true) {
233
- const jobs = await tryPullBatchFromShard(queue, idx, count, ctx);
272
+ const attempt = await tryPullBatchFromShard(queue, idx, count, ctx);
273
+ const jobs = attempt.jobs;
234
274
  if (jobs.length > 0) {
235
275
  let delivered;
236
276
  try {
@@ -260,9 +300,7 @@ export async function pullJobBatch(queue, count, timeoutMs, ctx) {
260
300
  if (deadline === 0 || now >= deadline) {
261
301
  return [];
262
302
  }
263
- // Wait for notification or timeout
264
- const remaining = deadline - now;
265
- await ctx.shards[idx].waitForJob(remaining);
303
+ await waitForNextCandidate(ctx.shards[idx], queue, deadline, now, attempt.nextRunAt);
266
304
  }
267
305
  }
268
306
  /**
@@ -274,28 +312,19 @@ async function tryPullBatchFromShard(queue, idx, count, ctx) {
274
312
  const state = shard.getState(queue);
275
313
  const jobs = [];
276
314
  if (state.paused)
277
- return jobs;
315
+ return { jobs, nextRunAt: null };
278
316
  const now = Date.now();
317
+ let nextRunAt = null;
279
318
  while (jobs.length < count) {
280
- // Check limits per job
281
- if (!shard.tryAcquireRateLimit(queue)) {
282
- break;
283
- }
284
- if (!shard.tryAcquireConcurrency(queue)) {
285
- break;
286
- }
287
319
  const result = tryDequeueNextJob(shard, queue, now, ctx);
288
320
  if (result.status === 'job') {
289
321
  jobs.push(result.job);
290
322
  }
291
323
  else {
292
- // 'stop' or 'skip': release the concurrency slot since no job was taken
293
- shard.releaseConcurrency(queue);
294
- if (result.status === 'stop')
295
- break;
296
- // 'skip': continue loop to try next job
324
+ nextRunAt = result.nextRunAt;
325
+ break;
297
326
  }
298
327
  }
299
- return jobs;
328
+ return { jobs, nextRunAt };
300
329
  });
301
330
  }
@@ -212,7 +212,7 @@ export async function pushJob(queue, input, ctx) {
212
212
  }
213
213
  // Insert to shard
214
214
  insertJobToShard(job, queue, shard, idx, ctx);
215
- shard.notify();
215
+ shard.notify(queue);
216
216
  result = { job, persisted: true };
217
217
  });
218
218
  if (!result) {
@@ -274,7 +274,7 @@ export async function pushJobBatch(queue, inputs, ctx) {
274
274
  resultIds.push(job.id);
275
275
  }
276
276
  if (jobsToInsert.length > 0) {
277
- shard.notifyBatch(jobsToInsert.length);
277
+ shard.notifyBatch(queue, jobsToInsert.length);
278
278
  }
279
279
  });
280
280
  if (jobsToInsert.length > 0) {