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.
- package/README.md +4 -3
- package/dist/application/backgroundTasks.js +17 -9
- package/dist/application/dependencyProcessor.js +6 -1
- package/dist/application/dlqManager.js +1 -1
- package/dist/application/lockManager.js +1 -1
- package/dist/application/operations/ack.js +23 -1
- package/dist/application/operations/jobManagement.js +2 -2
- package/dist/application/operations/jobStateTransitions.js +1 -1
- package/dist/application/operations/pull.js +125 -96
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/queryOperations.js +74 -70
- package/dist/application/queueManager.d.ts +6 -11
- package/dist/application/queueManager.js +16 -3
- package/dist/application/queueStatsAggregator.d.ts +23 -0
- package/dist/application/queueStatsAggregator.js +80 -0
- package/dist/application/statsManager.d.ts +2 -26
- package/dist/application/statsManager.js +8 -124
- package/dist/domain/queue/shard.d.ts +3 -1
- package/dist/domain/queue/shard.js +15 -7
- package/dist/domain/queue/temporalIndex.d.ts +24 -0
- package/dist/domain/queue/temporalIndex.js +100 -0
- package/dist/domain/queue/temporalManager.d.ts +2 -11
- package/dist/domain/queue/temporalManager.js +27 -44
- package/dist/domain/queue/waiterManager.d.ts +11 -13
- package/dist/domain/queue/waiterManager.js +80 -49
- package/dist/infrastructure/cloud/commands.js +5 -2
- package/dist/infrastructure/cloud/snapshotCollector.js +5 -2
- package/dist/infrastructure/cloud/statsRefresh.js +5 -2
- package/dist/infrastructure/cloud/statsUpdate.js +5 -2
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +13 -1
- package/dist/infrastructure/persistence/sqlite.d.ts +18 -2
- package/dist/infrastructure/persistence/sqlite.js +49 -6
- package/dist/infrastructure/server/queueCountsScheduler.d.ts +13 -0
- package/dist/infrastructure/server/queueCountsScheduler.js +35 -0
- package/dist/infrastructure/server/sseHandler.d.ts +1 -0
- package/dist/infrastructure/server/sseHandler.js +18 -13
- package/dist/infrastructure/server/wsHandler.d.ts +1 -1
- package/dist/infrastructure/server/wsHandler.js +18 -15
- 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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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]
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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) {
|