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
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">
@@ -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.4 MB** — and that includes the binary
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.4 MB** | **−94%** |
72
+ | **`node_modules`** | 93 MB | **5.5 MB** | **−94%** |
73
73
  | **packages** | 117 | **7** | **−110** |
74
- | **cold install** | ~6 s | **~1.2 s** | **~5× faster** |
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, after 5 min: 3, after 10 min: 5, â€Ķ capped at 100.
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
- Coalesce rapid same-name jobs. Only the last one in the TTL window gets processed:
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' }); // only this one processes
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 rate limiting (e.g., per customer)
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, 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,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 dependencies** — No Redis, no MongoDB
665
- - **Tiny footprint** — 5.4 MB installed, 7 packages (just 2 runtime deps: `croner` + `msgpackr`)
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 286K ops/sec** — [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
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.0 the MCP SDK is an optional peer dependency. Queue-only users skip it entirely:
694
- # a clean install drops from 93 MB / 117 packages to 5.4 MB / 7 packages (−94%, up to ~5× faster on a cold install).
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 286K ops/sec)
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 286K ops/sec). You get persistence, priorities, delays, retries, cron jobs, and DLQ — without the operational overhead of another service.
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** | 286K ops/sec | 149K ops/sec |
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) | 58/58 e2e per runtime + 11/11 inside workerd |
831
- | **Python â‰Ĩ 3.9** | PyPI coming soon — today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` | 44/44 e2e |
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 | 286K ops/sec | Same process |
913
- | TCP | 149K ops/sec | Distributed workers |
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
- 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
+ }
@@ -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 = ctx.shards[idx].addToDlq(validJob);
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',