bunqueue 2.8.31 → 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 (44) hide show
  1. package/README.md +29 -26
  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 +29 -1
  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/client/tcpPool.js +8 -1
  19. package/dist/domain/queue/shard.d.ts +3 -1
  20. package/dist/domain/queue/shard.js +15 -7
  21. package/dist/domain/queue/temporalIndex.d.ts +24 -0
  22. package/dist/domain/queue/temporalIndex.js +100 -0
  23. package/dist/domain/queue/temporalManager.d.ts +2 -11
  24. package/dist/domain/queue/temporalManager.js +27 -44
  25. package/dist/domain/queue/waiterManager.d.ts +11 -13
  26. package/dist/domain/queue/waiterManager.js +80 -49
  27. package/dist/infrastructure/cloud/commands.js +5 -2
  28. package/dist/infrastructure/cloud/snapshotCollector.js +5 -2
  29. package/dist/infrastructure/cloud/statsRefresh.js +5 -2
  30. package/dist/infrastructure/cloud/statsUpdate.js +5 -2
  31. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  32. package/dist/infrastructure/persistence/schema.js +13 -1
  33. package/dist/infrastructure/persistence/sqlite.d.ts +18 -2
  34. package/dist/infrastructure/persistence/sqlite.js +49 -6
  35. package/dist/infrastructure/server/handlers/core.js +13 -7
  36. package/dist/infrastructure/server/handlers/pushBatchValidation.d.ts +23 -0
  37. package/dist/infrastructure/server/handlers/pushBatchValidation.js +70 -0
  38. package/dist/infrastructure/server/queueCountsScheduler.d.ts +13 -0
  39. package/dist/infrastructure/server/queueCountsScheduler.js +35 -0
  40. package/dist/infrastructure/server/sseHandler.d.ts +1 -0
  41. package/dist/infrastructure/server/sseHandler.js +18 -13
  42. package/dist/infrastructure/server/wsHandler.d.ts +1 -1
  43. package/dist/infrastructure/server/wsHandler.js +18 -15
  44. package/package.json +3 -3
@@ -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) {
@@ -123,6 +123,17 @@ export function getJobProgress(jobId, ctx) {
123
123
  return null;
124
124
  return { progress: job.progress, message: job.progressMessage };
125
125
  }
126
+ /** Stable total order used by both in-memory collection and SQL queries. */
127
+ function compareJobsByCreatedAt(a, b, asc) {
128
+ if (a.createdAt !== b.createdAt) {
129
+ return asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt;
130
+ }
131
+ if (a.id === b.id)
132
+ return 0;
133
+ if (asc)
134
+ return a.id < b.id ? -1 : 1;
135
+ return a.id > b.id ? -1 : 1;
136
+ }
126
137
  /** Resolve job state from SQLite when jobIndex has no entry (post-restart recovery). */
127
138
  function resolveStateFromStorage(jobId, storage) {
128
139
  if (!storage)
@@ -197,29 +208,25 @@ export async function getJobState(jobId, ctx) {
197
208
  return 'unknown';
198
209
  }
199
210
  /** Collect completed jobs for a queue from index + storage */
200
- function collectCompletedJobs(queue, ctx, maxCollect) {
211
+ function collectCompletedJobs(queue, ctx) {
201
212
  const jobs = [];
202
213
  for (const [jId, location] of ctx.jobIndex) {
203
214
  if (location.type === 'completed' && location.queueName === queue) {
204
215
  const job = ctx.storage?.getJob(jId) ?? ctx.completedJobsData?.get(jId) ?? null;
205
216
  if (job) {
206
217
  jobs.push(job);
207
- if (jobs.length >= maxCollect)
208
- break;
209
218
  }
210
219
  }
211
220
  }
212
221
  return jobs;
213
222
  }
214
223
  /** Collect active jobs for a queue across all processing shards */
215
- function collectActiveJobs(queue, shardIdx, ctx, maxCollect) {
224
+ function collectActiveJobs(queue, shardIdx, ctx) {
216
225
  const jobs = [];
217
226
  // Own shard first (most likely location)
218
227
  for (const job of ctx.processingShards[shardIdx].values()) {
219
228
  if (job.queue === queue)
220
229
  jobs.push(job);
221
- if (jobs.length >= maxCollect)
222
- return jobs;
223
230
  }
224
231
  // Other shards
225
232
  for (let i = 0; i < ctx.shardCount; i++) {
@@ -228,20 +235,15 @@ function collectActiveJobs(queue, shardIdx, ctx, maxCollect) {
228
235
  for (const job of ctx.processingShards[i].values()) {
229
236
  if (job.queue === queue)
230
237
  jobs.push(job);
231
- if (jobs.length >= maxCollect)
232
- return jobs;
233
238
  }
234
239
  }
235
240
  return jobs;
236
241
  }
237
242
  /** Collect waiting/delayed/prioritized jobs in a single pass */
238
- function collectTemporalJobs(shard, queue, needs, maxCollect) {
243
+ function collectTemporalJobs(shard, queue, needs, now = Date.now()) {
239
244
  const { waiting: needWaiting, prioritized: needPrioritized, delayed: needDelayed } = needs;
240
- const now = Date.now();
241
245
  const jobs = [];
242
246
  for (const j of shard.getQueue(queue).values()) {
243
- if (jobs.length >= maxCollect)
244
- break;
245
247
  const isDelayed = j.runAt > now;
246
248
  if (isDelayed && needDelayed) {
247
249
  jobs.push(j);
@@ -262,8 +264,7 @@ function tagState(jobs, state) {
262
264
  return jobs;
263
265
  }
264
266
  /** Tag temporal jobs with their actual state based on runAt/priority */
265
- function tagTemporalState(jobs) {
266
- const now = Date.now();
267
+ function tagTemporalState(jobs, now = Date.now()) {
267
268
  for (const j of jobs) {
268
269
  const isDelayed = j.runAt > now;
269
270
  j._state = isDelayed
@@ -274,19 +275,15 @@ function tagTemporalState(jobs) {
274
275
  }
275
276
  }
276
277
  /** Collect waiting-children jobs from deps and children maps */
277
- function collectWaitingChildrenFromShard(shard, queue, max) {
278
+ function collectWaitingChildrenFromShard(shard, queue) {
278
279
  const wcJobs = [];
279
280
  for (const job of shard.waitingDeps.values()) {
280
281
  if (job.queue === queue)
281
282
  wcJobs.push(job);
282
- if (wcJobs.length >= max)
283
- return wcJobs;
284
283
  }
285
284
  for (const job of shard.waitingChildren.values()) {
286
285
  if (job.queue === queue)
287
286
  wcJobs.push(job);
288
- if (wcJobs.length >= max)
289
- return wcJobs;
290
287
  }
291
288
  return wcJobs;
292
289
  }
@@ -312,34 +309,33 @@ function resolveStateNeeds(states, paused) {
312
309
  };
313
310
  }
314
311
  /** When paused, the queue's ready jobs (waiting + prioritized) ARE the paused set (#92). */
315
- function collectPausedJobs(shard, queue, maxPerSource) {
316
- const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
312
+ function collectPausedJobs(shard, queue, now) {
313
+ const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, now);
317
314
  return tagState(pausedJobs, 'paused');
318
315
  }
319
- function collectJobsByState(queue, shardIdx, states, ctx, maxPerSource = Infinity) {
316
+ function collectJobsByState(queue, shardIdx, states, ctx, now = Date.now()) {
320
317
  const shard = ctx.shards[shardIdx];
321
318
  const jobs = [];
322
319
  const need = resolveStateNeeds(states, shard.getState(queue).paused);
323
320
  if (need.waiting || need.prioritized || need.delayed) {
324
- const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed }, maxPerSource);
325
- tagTemporalState(temporal);
321
+ const temporal = collectTemporalJobs(shard, queue, { waiting: need.waiting, prioritized: need.prioritized, delayed: need.delayed }, now);
322
+ tagTemporalState(temporal, now);
326
323
  jobs.push(...temporal);
327
324
  }
328
325
  if (need.paused) {
329
- jobs.push(...collectPausedJobs(shard, queue, maxPerSource));
326
+ jobs.push(...collectPausedJobs(shard, queue, now));
330
327
  }
331
328
  if (need.active) {
332
- jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx, maxPerSource), 'active'));
329
+ jobs.push(...tagState(collectActiveJobs(queue, shardIdx, ctx), 'active'));
333
330
  }
334
331
  if (need.failed) {
335
- const dlq = shard.getDlq(queue);
336
- jobs.push(...tagState(maxPerSource < dlq.length ? dlq.slice(0, maxPerSource) : dlq, 'failed'));
332
+ jobs.push(...tagState(shard.getDlq(queue), 'failed'));
337
333
  }
338
334
  if (need.completed) {
339
- jobs.push(...tagState(collectCompletedJobs(queue, ctx, maxPerSource), 'completed'));
335
+ jobs.push(...tagState(collectCompletedJobs(queue, ctx), 'completed'));
340
336
  }
341
337
  if (need.waitingChildren) {
342
- jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue, maxPerSource), 'waiting-children'));
338
+ jobs.push(...tagState(collectWaitingChildrenFromShard(shard, queue), 'waiting-children'));
343
339
  }
344
340
  return jobs;
345
341
  }
@@ -356,38 +352,34 @@ function collectWaitingChildrenJobs(shard, queue) {
356
352
  }
357
353
  return jobs;
358
354
  }
359
- /** Query SQLite with priority/waiting translation */
360
- function querySqliteWithPriority(storage, queue, sqlFilteredStates, opts) {
361
- const hasPrioritized = sqlFilteredStates.includes('prioritized');
362
- const hasWaiting = sqlFilteredStates.includes('waiting');
363
- if (!hasPrioritized && !hasWaiting) {
364
- if (sqlFilteredStates.length === 1) {
365
- return storage.queryJobs(queue, { state: sqlFilteredStates[0], ...opts });
366
- }
367
- return storage.queryJobs(queue, { states: sqlFilteredStates, ...opts });
368
- }
369
- // Map 'prioritized' to 'waiting' for SQLite, then post-filter by priority
370
- const sqlStates = sqlFilteredStates
371
- .map((s) => (s === 'prioritized' ? 'waiting' : s))
372
- .filter((s, i, arr) => arr.indexOf(s) === i);
373
- const overFetchOpts = { ...opts, limit: opts.limit * 2 };
374
- let jobs = sqlStates.length === 1
375
- ? storage.queryJobs(queue, { state: sqlStates[0], ...overFetchOpts })
376
- : storage.queryJobs(queue, { states: sqlStates, ...overFetchOpts });
377
- if (hasPrioritized && !hasWaiting) {
378
- jobs = jobs.filter((j) => j.priority > 0);
355
+ /** Query SQLite after translating persisted pending rows to their logical state. */
356
+ function querySqliteByLogicalState(storage, queue, sqlFilteredStates, opts) {
357
+ if (opts.excludedIds.size === 0) {
358
+ return storage.queryJobsByLogicalStates(queue, sqlFilteredStates, opts);
379
359
  }
380
- else if (hasWaiting && !hasPrioritized) {
381
- jobs = jobs.filter((j) => j.priority <= 0);
382
- }
383
- return jobs.slice(0, opts.limit);
360
+ // waitingDeps/waitingChildren rows remain persisted as waiting/delayed. Fetch
361
+ // enough rows to account for every possible exclusion, then paginate the
362
+ // logical result so parked jobs cannot create short or shifted pages.
363
+ const pageEnd = opts.offset + opts.limit;
364
+ const jobs = storage.queryJobsByLogicalStates(queue, sqlFilteredStates, {
365
+ limit: pageEnd + opts.excludedIds.size,
366
+ offset: 0,
367
+ asc: opts.asc,
368
+ now: opts.now,
369
+ });
370
+ return jobs.filter((job) => !opts.excludedIds.has(job.id)).slice(opts.offset, pageEnd);
384
371
  }
385
372
  /** Merge SQL rows with in-memory extras (each gathered from index 0), sort by
386
373
  * createdAt, and paginate [start, end) once — so offset-unaware extras don't
387
374
  * duplicate or drop rows across pages (#92). */
388
375
  function mergePage(sqlJobs, extras, start, end, asc) {
389
- const merged = sqlJobs.concat(extras);
390
- merged.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
376
+ const byId = new Map();
377
+ for (const job of sqlJobs)
378
+ byId.set(job.id, job);
379
+ for (const job of extras)
380
+ byId.set(job.id, job);
381
+ const merged = Array.from(byId.values());
382
+ merged.sort((a, b) => compareJobsByCreatedAt(a, b, asc));
391
383
  return merged.slice(start, end);
392
384
  }
393
385
  /** Get jobs from queue with filters */
@@ -401,10 +393,10 @@ export function getJobs(queue, shardIdx, options, ctx) {
401
393
  : state
402
394
  : [state];
403
395
  const limit = end - start;
396
+ const now = Date.now();
404
397
  if (ctx.storage) {
405
398
  const shard = ctx.shards[shardIdx];
406
399
  const isPaused = shard.getState(queue).paused;
407
- const maxPerSource = end + 1;
408
400
  // Derived sources are NOT offset-aware (the DLQ and the paused/waiting-children
409
401
  // views come from in-memory maps). When any contributes, we must gather [0, end)
410
402
  // from EVERY source, merge, sort, then slice [start, end) exactly once — pushing
@@ -417,6 +409,19 @@ export function getJobs(queue, shardIdx, options, ctx) {
417
409
  const all = ctx.storage.queryJobs(queue, { limit: end, offset: 0, asc });
418
410
  return mergePage(all, dlq, start, end, asc);
419
411
  }
412
+ // jobs-table states. A paused queue reports its waiting/prioritized jobs under
413
+ // 'paused', so they must not also surface in the waiting/prioritized lists (#92).
414
+ const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
415
+ s !== 'waiting-children' &&
416
+ s !== 'paused' &&
417
+ !(isPaused && (s === 'waiting' || s === 'prioritized')));
418
+ // In-memory parked state is authoritative over the persisted row. Initial
419
+ // dependency jobs are stored as waiting/delayed, while parents moved after
420
+ // a pull can still be stored as active.
421
+ const parkedJobs = states.includes('waiting-children') || sqlFilteredStates.length > 0
422
+ ? collectWaitingChildrenJobs(shard, queue)
423
+ : [];
424
+ const parkedIds = new Set(parkedJobs.map((job) => job.id));
420
425
  // States with no jobs-table row are collected from in-memory sources:
421
426
  // - 'failed' -> DLQ (the failed job lives in the dlq table) (#92)
422
427
  // - 'waiting-children' -> deps/children maps
@@ -426,40 +431,39 @@ export function getJobs(queue, shardIdx, options, ctx) {
426
431
  extras.push(...tagState(shard.getDlq(queue), 'failed'));
427
432
  }
428
433
  if (states.includes('waiting-children')) {
429
- extras.push(...collectWaitingChildrenJobs(shard, queue));
434
+ extras.push(...tagState(parkedJobs, 'waiting-children'));
430
435
  }
431
436
  if (states.includes('paused') && isPaused) {
432
- const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, maxPerSource);
437
+ const pausedJobs = collectTemporalJobs(shard, queue, { waiting: true, prioritized: true, delayed: false }, now);
433
438
  extras.push(...tagState(pausedJobs, 'paused'));
434
439
  }
435
- // jobs-table states. A paused queue reports its waiting/prioritized jobs under
436
- // 'paused', so they must not also surface in the waiting/prioritized lists (#92).
437
- const sqlFilteredStates = states.filter((s) => s !== 'failed' &&
438
- s !== 'waiting-children' &&
439
- s !== 'paused' &&
440
- !(isPaused && (s === 'waiting' || s === 'prioritized')));
441
440
  // Fast path: only jobs-table states, no derived sources — SQL paginates directly.
442
441
  if (extras.length === 0) {
443
442
  return sqlFilteredStates.length > 0
444
- ? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
443
+ ? querySqliteByLogicalState(ctx.storage, queue, sqlFilteredStates, {
445
444
  limit,
446
445
  offset: start,
447
446
  asc,
447
+ now,
448
+ excludedIds: parkedIds,
448
449
  })
449
450
  : [];
450
451
  }
451
452
  const sqlJobs = sqlFilteredStates.length > 0
452
- ? querySqliteWithPriority(ctx.storage, queue, sqlFilteredStates, {
453
+ ? querySqliteByLogicalState(ctx.storage, queue, sqlFilteredStates, {
453
454
  limit: end,
454
455
  offset: 0,
455
456
  asc,
457
+ now,
458
+ excludedIds: parkedIds,
456
459
  })
457
460
  : [];
458
461
  return mergePage(sqlJobs, extras, start, end, asc);
459
462
  }
460
463
  // In-memory path (embedded mode only)
461
- const maxPerSource = end + 1;
462
- const jobs = collectJobsByState(queue, shardIdx, states, ctx, maxPerSource);
463
- jobs.sort((a, b) => (asc ? a.createdAt - b.createdAt : b.createdAt - a.createdAt));
464
+ // Every source must be filtered before the global order is applied. Limiting
465
+ // an insertion-ordered Map here loses newer rows on descending pages.
466
+ const jobs = collectJobsByState(queue, shardIdx, states, ctx, now);
467
+ jobs.sort((a, b) => compareJobsByCreatedAt(a, b, asc));
464
468
  return jobs.slice(start, end);
465
469
  }
@@ -341,24 +341,19 @@ export declare class QueueManager {
341
341
  paused: boolean;
342
342
  counts: {
343
343
  waiting: number;
344
+ prioritized: number;
344
345
  active: number;
345
346
  completed: number;
346
347
  failed: number;
347
348
  delayed: number;
348
349
  };
349
350
  }>;
351
+ /** Get counts for every registered queue with one global aggregation pass. */
352
+ getAllQueueJobCounts(): Map<string, statsMgr.QueueJobCounts>;
353
+ /** Aggregate a selected group of queues without repeating global scans. */
354
+ getQueueJobCountsBatch(queueNames: Iterable<string>): Map<string, statsMgr.QueueJobCounts>;
350
355
  /** Get job counts for a specific queue */
351
- getQueueJobCounts(queueName: string): {
352
- waiting: number;
353
- prioritized: number;
354
- delayed: number;
355
- active: number;
356
- completed: number;
357
- failed: number;
358
- 'waiting-children': number;
359
- totalCompleted: number;
360
- totalFailed: number;
361
- };
356
+ getQueueJobCounts(queueName: string): statsMgr.QueueJobCounts;
362
357
  getMemoryStats(): statsMgr.MemoryStats;
363
358
  /** Get storage health status (disk full detection) */
364
359
  getStorageStatus(): {