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.
- package/README.md +29 -26
- 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 +29 -1
- 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/client/tcpPool.js +8 -1
- 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/handlers/core.js +13 -7
- package/dist/infrastructure/server/handlers/pushBatchValidation.d.ts +23 -0
- package/dist/infrastructure/server/handlers/pushBatchValidation.js +70 -0
- 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">
|
|
@@ -63,15 +63,15 @@ That's it. Queue + Worker in one object. No Redis, no config, no setup.
|
|
|
63
63
|
|
|
64
64
|
## ðŠķ Featherweight install
|
|
65
65
|
|
|
66
|
-
`bun add bunqueue` pulls **7 packages and 5.
|
|
66
|
+
`bun add bunqueue` pulls **7 packages and 5.5 MB** â and that includes the binary
|
|
67
67
|
queue server, the CLI, and the MCP server. Most queue libraries pull a Redis
|
|
68
68
|
client and its dependency tree before you've queued a single job.
|
|
69
69
|
|
|
70
70
|
| | Before (2.7.x) | Now | |
|
|
71
71
|
| --- | --- | --- | --- |
|
|
72
|
-
| **`node_modules`** | 93 MB | **5.
|
|
72
|
+
| **`node_modules`** | 93 MB | **5.5 MB** | **â94%** |
|
|
73
73
|
| **packages** | 117 | **7** | **â110** |
|
|
74
|
-
| **cold install** | ~6 s | **~1.
|
|
74
|
+
| **cold install** | ~6 s | **~1.7 s** | **~3.5Ã faster** |
|
|
75
75
|
|
|
76
76
|
Just **2 runtime dependencies** (`croner` + `msgpackr`). SQLite, S3, HTTP and
|
|
77
77
|
WebSocket are all Bun built-ins. The MCP SDK is an optional peer dependency â
|
|
@@ -336,7 +336,7 @@ const app = new Bunqueue('tasks', {
|
|
|
336
336
|
});
|
|
337
337
|
```
|
|
338
338
|
|
|
339
|
-
A job with priority 1
|
|
339
|
+
A job with priority 1 becomes eligible after 5 min, then gains +2 on every 60s tick: 3, 5, 7, âĶ capped at 100.
|
|
340
340
|
|
|
341
341
|
### Deduplication
|
|
342
342
|
|
|
@@ -362,7 +362,7 @@ Override per-job: `await app.add('task', data, { deduplication: { id: 'my-id', t
|
|
|
362
362
|
|
|
363
363
|
### Debouncing
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
Attach a default debounce id to same-name jobs. Each job gets `debounce: { id: jobName, ttl }` unless the per-job options already set one:
|
|
366
366
|
|
|
367
367
|
```typescript
|
|
368
368
|
const app = new Bunqueue('search', {
|
|
@@ -373,9 +373,11 @@ const app = new Bunqueue('search', {
|
|
|
373
373
|
|
|
374
374
|
await app.add('search', { query: 'h' });
|
|
375
375
|
await app.add('search', { query: 'he' });
|
|
376
|
-
await app.add('search', { query: 'hello' }); //
|
|
376
|
+
await app.add('search', { query: 'hello' }); // all three share the debounce id 'search'
|
|
377
377
|
```
|
|
378
378
|
|
|
379
|
+
The debounce id and TTL are stored on the job (BullMQ v5 compatible metadata, visible via `job.opts.debounce`), but they do not suppress duplicates by themselves in the current engine. To actually coalesce rapid duplicates, use [Deduplication](#deduplication) with `replace: true`: a duplicate arriving within the TTL window replaces the pending job, giving last-write-wins semantics.
|
|
380
|
+
|
|
379
381
|
### Rate Limiting
|
|
380
382
|
|
|
381
383
|
```typescript
|
|
@@ -385,15 +387,13 @@ const app = new Bunqueue('api', {
|
|
|
385
387
|
rateLimit: { max: 100, duration: 1000 },
|
|
386
388
|
});
|
|
387
389
|
|
|
388
|
-
// Per-group
|
|
390
|
+
// Per-group cap (e.g., per customer): with groupKey set, max limits
|
|
391
|
+
// concurrently active jobs per group (duration is not applied in this mode)
|
|
389
392
|
const app2 = new Bunqueue('api', {
|
|
390
393
|
embedded: true,
|
|
391
394
|
processor: async (job) => callAPI(job.data),
|
|
392
395
|
rateLimit: { max: 10, duration: 1000, groupKey: 'customerId' },
|
|
393
396
|
});
|
|
394
|
-
|
|
395
|
-
app.setGlobalRateLimit(50, 1000);
|
|
396
|
-
app.removeGlobalRateLimit();
|
|
397
397
|
```
|
|
398
398
|
|
|
399
399
|
### DLQ (Dead Letter Queue)
|
|
@@ -418,7 +418,7 @@ app.retryDlq(); // retry all
|
|
|
418
418
|
app.purgeDlq(); // clear all
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
Failure reasons: `explicit_fail`, `max_attempts_exceeded`, `timeout`, `stalled`, `ttl_expired`, `worker_lost`.
|
|
421
|
+
Failure reasons: `explicit_fail`, `max_attempts_exceeded`, `timeout`, `stalled`, `ttl_expired`, `worker_lost`, `unknown`.
|
|
422
422
|
|
|
423
423
|
### Cron Jobs
|
|
424
424
|
|
|
@@ -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,10 +662,10 @@ 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
|
-
- **Tiny footprint** â 5.
|
|
665
|
+
- **Zero external infrastructure** â No Redis, no MongoDB
|
|
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
|
-
- **Up to
|
|
668
|
+
- **Up to 630K ops/sec** â [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
|
|
668
669
|
|
|
669
670
|
## Built for AI Agents (MCP Server)
|
|
670
671
|
|
|
@@ -690,8 +691,8 @@ bunqueue is the **first job queue with native MCP support**. AI agents get a ful
|
|
|
690
691
|
# bunqueue-mcp is a binary bundled inside the bunqueue package (not a standalone npm package),
|
|
691
692
|
# so install bunqueue first, then connect Claude Code:
|
|
692
693
|
bun add bunqueue
|
|
693
|
-
# Since v2.8.
|
|
694
|
-
# a clean install drops from 93 MB / 117 packages to 5.
|
|
694
|
+
# Since v2.8.1 the MCP SDK is an optional peer dependency. Queue-only users skip it entirely:
|
|
695
|
+
# a clean install drops from 93 MB / 117 packages to 5.5 MB / 7 packages (â94%, ~3.5Ã faster on a cold install).
|
|
695
696
|
# bunx --package=bunqueue does NOT auto-install optional peers, so install the SDK once to run the MCP server:
|
|
696
697
|
bun add @modelcontextprotocol/sdk
|
|
697
698
|
claude mcp add bunqueue -- bunx bunqueue-mcp
|
|
@@ -731,7 +732,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
|
|
|
731
732
|
- **Agentic workflows** â agents push jobs, workers process, agents monitor results
|
|
732
733
|
- Single-server deployments
|
|
733
734
|
- Prototypes and MVPs
|
|
734
|
-
- Moderate to high workloads (up to
|
|
735
|
+
- Moderate to high workloads (up to 630K ops/sec)
|
|
735
736
|
- Teams that want to avoid Redis operational overhead
|
|
736
737
|
- Embedded use cases (CLI tools, edge functions, serverless)
|
|
737
738
|
|
|
@@ -745,7 +746,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
|
|
|
745
746
|
|
|
746
747
|
If you're already running Redis, BullMQ is great â battle-tested and feature-rich.
|
|
747
748
|
|
|
748
|
-
bunqueue is for when you **don't want to run Redis**. SQLite with WAL mode handles surprisingly high throughput for single-node deployments (tested up to
|
|
749
|
+
bunqueue is for when you **don't want to run Redis**. SQLite with WAL mode handles surprisingly high throughput for single-node deployments (tested up to 630K ops/sec on bulk push). You get persistence, priorities, delays, retries, cron jobs, and DLQ â without the operational overhead of another service.
|
|
749
750
|
|
|
750
751
|
## Install
|
|
751
752
|
|
|
@@ -765,13 +766,15 @@ bunqueue runs in two modes depending on your architecture:
|
|
|
765
766
|
| ---------------- | ------------------------------------- | -------------------------------------------- |
|
|
766
767
|
| **How it works** | Queue runs inside your process | Standalone server, clients connect via TCP |
|
|
767
768
|
| **Setup** | `bun add bunqueue` | `docker run` or `bunqueue start` |
|
|
768
|
-
| **Performance** |
|
|
769
|
+
| **Performance** | 630K ops/sec (bulk push) | 90K ops/sec (push) |
|
|
769
770
|
| **Best for** | Single-process apps, CLIs, serverless | Multiple workers, separate producer/consumer |
|
|
770
771
|
| **Scaling** | Same process only | Multiple clients across machines |
|
|
771
772
|
|
|
772
773
|
### Embedded Mode
|
|
773
774
|
|
|
774
|
-
Everything runs in your process. No server, no network, no setup.
|
|
775
|
+
Everything runs in your process. No server, no network, no setup. Without a
|
|
776
|
+
data path the queue is in-memory and lost on restart: pass
|
|
777
|
+
`dataPath: './data/app.db'` (or set `BUNQUEUE_DATA_PATH`) to persist jobs.
|
|
775
778
|
|
|
776
779
|
```typescript
|
|
777
780
|
import { Queue, Worker } from 'bunqueue/client';
|
|
@@ -827,8 +830,8 @@ full feature parity, so producers and workers can live anywhere in your stack:
|
|
|
827
830
|
|
|
828
831
|
| Where your code runs | Install | Status |
|
|
829
832
|
| -------------------- | ------- | ------ |
|
|
830
|
-
| **Node.js âĨ 20, Deno âĨ 2, Bun, Cloudflare Workers** | [`npm install bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) |
|
|
831
|
-
| **Python âĨ 3.9** | PyPI coming soon â today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` |
|
|
833
|
+
| **Node.js âĨ 20, Deno âĨ 2, Bun, Cloudflare Workers** | [`npm install bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) | 100/100 e2e per runtime + 16/16 inside workerd |
|
|
834
|
+
| **Python âĨ 3.9** | PyPI coming soon â today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` | 78/78 e2e |
|
|
832
835
|
|
|
833
836
|
```typescript
|
|
834
837
|
// Node.js / Deno / Cloudflare Workers
|
|
@@ -909,8 +912,8 @@ SQLite handles surprisingly high throughput for single-node deployments:
|
|
|
909
912
|
|
|
910
913
|
| Mode | Peak Throughput | Use Case |
|
|
911
914
|
| -------- | --------------- | ------------------- |
|
|
912
|
-
| Embedded |
|
|
913
|
-
| TCP |
|
|
915
|
+
| Embedded | 630K ops/sec (bulk push) | Same process |
|
|
916
|
+
| TCP | 90K ops/sec (push) | Distributed workers |
|
|
914
917
|
|
|
915
918
|
> Run `bun run bench` to verify on your hardware. [Full benchmark methodology â](https://bunqueue.dev/guide/benchmarks/)
|
|
916
919
|
|
|
@@ -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
|
+
}
|
|
@@ -200,11 +200,19 @@ export async function moveJobToDelayed(jobId, delay, ctx) {
|
|
|
200
200
|
const queueName = job.queue;
|
|
201
201
|
await withWriteLock(ctx.shardLocks[idx], () => {
|
|
202
202
|
const shard = ctx.shards[idx];
|
|
203
|
+
// Release the concurrency slot (+group+uniqueKey) acquired at pull before
|
|
204
|
+
// re-queueing, mirroring moveActiveToWait (jobStateTransitions.ts) --
|
|
205
|
+
// otherwise the slot leaks and setConcurrency(N) wedges after N moves.
|
|
206
|
+
// Dropping the uniqueKey reservation on requeue matches the sibling
|
|
207
|
+
// requeue paths (failJob retry, moveActiveToWait, requeueExpiredJob).
|
|
208
|
+
shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
|
|
203
209
|
shard.getQueue(job.queue).push(job);
|
|
204
210
|
// Update running counters for O(1) stats and temporal index (job is delayed since delay > 0)
|
|
205
211
|
const isDelayed = job.runAt > now;
|
|
206
212
|
shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
|
|
207
213
|
ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
|
|
214
|
+
// The freed slot may unblock waiting jobs for long-pollers.
|
|
215
|
+
shard.notify(job.queue);
|
|
208
216
|
});
|
|
209
217
|
// Persist the move so the delay survives a restart: the on-disk row was
|
|
210
218
|
// `state='active'` with the old run_at, which recovery would otherwise treat as a
|
|
@@ -250,9 +258,29 @@ export async function discardJob(jobId, ctx) {
|
|
|
250
258
|
if (job) {
|
|
251
259
|
const validJob = job; // Local reference for closure
|
|
252
260
|
const idx = shardIndex(validJob.queue);
|
|
261
|
+
const fromProcessing = location.type === 'processing';
|
|
253
262
|
const entry = await withWriteLock(ctx.shardLocks[idx], () => {
|
|
263
|
+
const shard = ctx.shards[idx];
|
|
264
|
+
if (fromProcessing) {
|
|
265
|
+
// The pull that activated this job acquired a concurrency slot (and
|
|
266
|
+
// possibly a FIFO group); return them before the DLQ move or the slot
|
|
267
|
+
// leaks and setConcurrency(N) wedges after N discards. The uniqueKey
|
|
268
|
+
// is freed too: every sibling terminal DLQ path releases the full
|
|
269
|
+
// reservation on entry (failJob, ack.ts; handleMaxStallsExceeded,
|
|
270
|
+
// lockManager.ts), so the DLQ entry never keeps it.
|
|
271
|
+
shard.releaseJobResources(validJob.queue, validJob.uniqueKey, validJob.groupId);
|
|
272
|
+
// The freed slot may unblock waiting jobs for long-pollers.
|
|
273
|
+
shard.notify(validJob.queue);
|
|
274
|
+
}
|
|
275
|
+
else if (validJob.uniqueKey) {
|
|
276
|
+
// Queue branch: the waiting job never acquired a slot or group at
|
|
277
|
+
// pull, so a full releaseJobResources would free a slot legitimately
|
|
278
|
+
// held by ANOTHER active job. Release only the uniqueKey reservation,
|
|
279
|
+
// matching cancelJob and the DLQ-entry semantics above.
|
|
280
|
+
shard.releaseUniqueKey(validJob.queue, validJob.uniqueKey);
|
|
281
|
+
}
|
|
254
282
|
// addToDlq already updates dlq counter and returns entry
|
|
255
|
-
const dlqEntry =
|
|
283
|
+
const dlqEntry = shard.addToDlq(validJob);
|
|
256
284
|
ctx.jobIndex.set(jobId, { type: 'dlq', queueName: validJob.queue });
|
|
257
285
|
return dlqEntry;
|
|
258
286
|
});
|
|
@@ -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',
|