bunqueue 2.8.31 → 2.8.32

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 CHANGED
@@ -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
 
@@ -662,9 +662,9 @@ bunx bunqueue-dashboard
662
662
  - **MCP server included** — 73 tools, 5 resources, 3 prompts. AI agents get full control out of the box
663
663
  - **BullMQ-compatible API** — Same `Queue`, `Worker`, `QueueEvents`
664
664
  - **Zero dependencies** — No Redis, no MongoDB
665
- - **Tiny footprint** — 5.4 MB installed, 7 packages (just 2 runtime deps: `croner` + `msgpackr`)
665
+ - **Tiny footprint** — 5.5 MB installed, 7 packages (just 2 runtime deps: `croner` + `msgpackr`)
666
666
  - **SQLite persistence** — Survives restarts, WAL mode for concurrent access
667
- - **Up to 286K ops/sec** — [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
667
+ - **Up to 630K ops/sec** — [Verified benchmarks](https://bunqueue.dev/guide/benchmarks/)
668
668
 
669
669
  ## Built for AI Agents (MCP Server)
670
670
 
@@ -690,8 +690,8 @@ bunqueue is the **first job queue with native MCP support**. AI agents get a ful
690
690
  # bunqueue-mcp is a binary bundled inside the bunqueue package (not a standalone npm package),
691
691
  # so install bunqueue first, then connect Claude Code:
692
692
  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).
693
+ # Since v2.8.1 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.5 MB / 7 packages (−94%, ~3.5× faster on a cold install).
695
695
  # bunx --package=bunqueue does NOT auto-install optional peers, so install the SDK once to run the MCP server:
696
696
  bun add @modelcontextprotocol/sdk
697
697
  claude mcp add bunqueue -- bunx bunqueue-mcp
@@ -731,7 +731,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
731
731
  - **Agentic workflows** — agents push jobs, workers process, agents monitor results
732
732
  - Single-server deployments
733
733
  - Prototypes and MVPs
734
- - Moderate to high workloads (up to 286K ops/sec)
734
+ - Moderate to high workloads (up to 630K ops/sec)
735
735
  - Teams that want to avoid Redis operational overhead
736
736
  - Embedded use cases (CLI tools, edge functions, serverless)
737
737
 
@@ -745,7 +745,7 @@ Supports **embedded** (local SQLite) and **TCP** (remote server) modes. [Full MC
745
745
 
746
746
  If you're already running Redis, BullMQ is great — battle-tested and feature-rich.
747
747
 
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.
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 630K ops/sec on bulk push). You get persistence, priorities, delays, retries, cron jobs, and DLQ — without the operational overhead of another service.
749
749
 
750
750
  ## Install
751
751
 
@@ -765,13 +765,15 @@ bunqueue runs in two modes depending on your architecture:
765
765
  | ---------------- | ------------------------------------- | -------------------------------------------- |
766
766
  | **How it works** | Queue runs inside your process | Standalone server, clients connect via TCP |
767
767
  | **Setup** | `bun add bunqueue` | `docker run` or `bunqueue start` |
768
- | **Performance** | 286K ops/sec | 149K ops/sec |
768
+ | **Performance** | 630K ops/sec (bulk push) | 90K ops/sec (push) |
769
769
  | **Best for** | Single-process apps, CLIs, serverless | Multiple workers, separate producer/consumer |
770
770
  | **Scaling** | Same process only | Multiple clients across machines |
771
771
 
772
772
  ### Embedded Mode
773
773
 
774
- Everything runs in your process. No server, no network, no setup.
774
+ Everything runs in your process. No server, no network, no setup. Without a
775
+ data path the queue is in-memory and lost on restart: pass
776
+ `dataPath: './data/app.db'` (or set `BUNQUEUE_DATA_PATH`) to persist jobs.
775
777
 
776
778
  ```typescript
777
779
  import { Queue, Worker } from 'bunqueue/client';
@@ -827,8 +829,8 @@ full feature parity, so producers and workers can live anywhere in your stack:
827
829
 
828
830
  | Where your code runs | Install | Status |
829
831
  | -------------------- | ------- | ------ |
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 |
832
+ | **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 |
833
+ | **Python â‰Ĩ 3.9** | PyPI coming soon — today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` | 78/78 e2e |
832
834
 
833
835
  ```typescript
834
836
  // Node.js / Deno / Cloudflare Workers
@@ -909,8 +911,8 @@ SQLite handles surprisingly high throughput for single-node deployments:
909
911
 
910
912
  | Mode | Peak Throughput | Use Case |
911
913
  | -------- | --------------- | ------------------- |
912
- | Embedded | 286K ops/sec | Same process |
913
- | TCP | 149K ops/sec | Distributed workers |
914
+ | Embedded | 630K ops/sec (bulk push) | Same process |
915
+ | TCP | 90K ops/sec (push) | Distributed workers |
914
916
 
915
917
  > Run `bun run bench` to verify on your hardware. [Full benchmark methodology →](https://bunqueue.dev/guide/benchmarks/)
916
918
 
@@ -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();
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();
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
  });
@@ -50,6 +50,8 @@ export class TcpConnectionPool {
50
50
  pingInterval: this.options.pingInterval,
51
51
  maxPingFailures: this.options.maxPingFailures,
52
52
  maxCommandTimeouts: this.options.maxCommandTimeouts,
53
+ pipelining: this.options.pipelining,
54
+ maxInFlight: this.options.maxInFlight,
53
55
  });
54
56
  // Socket-level errors (e.g. TLS handshake failures, protocol garbage) are
55
57
  // emitted as 'error' events; without a listener EventEmitter would throw
@@ -190,7 +192,12 @@ function getPoolKey(options) {
190
192
  // TLS config must differentiate pools too: a TLS pool and a plaintext pool
191
193
  // to the same host:port are NOT interchangeable.
192
194
  const tlsKey = options?.tls ? JSON.stringify(options.tls) : '0';
193
- return `${host}:${port}:${poolSize}:${tokenHash}:${tlsKey}`;
195
+ // pipelining/maxInFlight shape per-connection behavior: without them in the
196
+ // key, two Queues with different windows would silently share whichever
197
+ // pool was created first.
198
+ const pipelining = (options?.pipelining ?? true) ? '1' : '0';
199
+ const maxInFlight = options?.maxInFlight ?? 100;
200
+ return `${host}:${port}:${poolSize}:${tokenHash}:${tlsKey}:${pipelining}:${maxInFlight}`;
194
201
  }
195
202
  /** Get or create shared connection pool */
196
203
  export function getSharedPool(options) {
@@ -5,6 +5,7 @@
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
7
  import { validateQueueName, validateJobData, validateJobOptions, validateNumericField, } from '../protocol';
8
+ import { validatePushBatchJobs } from './pushBatchValidation';
8
9
  /** Handle PUSH command */
9
10
  export async function handlePush(cmd, ctx, reqId) {
10
11
  const queueError = validateQueueName(cmd.queue);
@@ -86,11 +87,11 @@ export async function handlePushBatch(cmd, ctx, reqId) {
86
87
  const queueError = validateQueueName(cmd.queue);
87
88
  if (queueError)
88
89
  return resp.error(queueError, reqId);
89
- for (const job of cmd.jobs) {
90
- const dataError = validateJobData(job.data);
91
- if (dataError)
92
- return resp.error(dataError, reqId);
93
- }
90
+ // PUSH parity: per-job data/option bounds + dependsOn existence gate
91
+ // (extended to earlier same-batch custom ids for intra-batch chains).
92
+ const jobsError = validatePushBatchJobs(cmd.jobs, ctx);
93
+ if (jobsError)
94
+ return resp.error(jobsError, reqId);
94
95
  const ids = await ctx.queueManager.pushBatch(cmd.queue, cmd.jobs);
95
96
  return resp.batch(ids, reqId);
96
97
  }
@@ -128,6 +129,10 @@ export async function handlePullBatch(cmd, ctx, reqId) {
128
129
  const countError = validateNumericField(cmd.count, 'count', { min: 1, max: 1000 });
129
130
  if (countError)
130
131
  return resp.error(countError, reqId);
132
+ // Validate timeout (same bound as PULL)
133
+ const timeoutError = validateNumericField(cmd.timeout, 'timeout', { min: 0, max: 60000 });
134
+ if (timeoutError)
135
+ return resp.error(timeoutError, reqId);
131
136
  // If owner is provided, use lock-based pull
132
137
  if (cmd.owner) {
133
138
  const { jobs, tokens } = await ctx.queueManager.pullBatchWithLock(cmd.queue, cmd.count, cmd.owner, cmd.timeout ?? 0, cmd.lockTtl);
@@ -139,8 +144,9 @@ export async function handlePullBatch(cmd, ctx, reqId) {
139
144
  }
140
145
  return resp.pulledJobs(jobs, tokens, reqId);
141
146
  }
142
- // Standard pull (no locks, but still track for client release)
143
- const jobs = await ctx.queueManager.pullBatch(cmd.queue, cmd.count, 0);
147
+ // Standard pull (no locks, but still track for client release) — the
148
+ // non-owner branch honors cmd.timeout exactly like the owner branch and PULL.
149
+ const jobs = await ctx.queueManager.pullBatch(cmd.queue, cmd.count, cmd.timeout ?? 0);
144
150
  if (ctx.clientId) {
145
151
  for (const job of jobs) {
146
152
  ctx.queueManager.registerClientJob(ctx.clientId, job.id);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * PUSHB Validation
3
+ * Per-job validation for batch pushes with PUSH parity: the same
4
+ * validateJobData/validateJobOptions bounds and the same dependsOn
5
+ * existence gate handlePush enforces (core.ts).
6
+ */
7
+ import type { JobInput } from '../../../domain/types/job';
8
+ import type { HandlerContext } from '../types';
9
+ /**
10
+ * Validate every job of a PUSHB batch. Returns an error message naming the
11
+ * offending index, or null when the whole batch is valid.
12
+ *
13
+ * The dependsOn gate is EXTENDED beyond the PUSH one: a dependency may also
14
+ * reference ANY OTHER job of the same batch via its custom id (the only ids
15
+ * a client can know before the batch is applied). Order within the batch is
16
+ * deliberately irrelevant: the default-on auto-batcher groups concurrent
17
+ * add() calls in arbitrary order, so a child can legitimately precede its
18
+ * parent in the array, and the readiness layer (waitingDeps) resolves the
19
+ * chain once the whole batch is applied. Self-references are rejected, they
20
+ * can never resolve. jobIndex/completedJobs/depCompletions are hoisted out
21
+ * of the loop because PUSHB is the bulk hot path.
22
+ */
23
+ export declare function validatePushBatchJobs(jobs: JobInput[], ctx: HandlerContext): string | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PUSHB Validation
3
+ * Per-job validation for batch pushes with PUSH parity: the same
4
+ * validateJobData/validateJobOptions bounds and the same dependsOn
5
+ * existence gate handlePush enforces (core.ts).
6
+ */
7
+ import { validateJobData, validateJobOptions } from '../protocol';
8
+ /**
9
+ * Validate every job of a PUSHB batch. Returns an error message naming the
10
+ * offending index, or null when the whole batch is valid.
11
+ *
12
+ * The dependsOn gate is EXTENDED beyond the PUSH one: a dependency may also
13
+ * reference ANY OTHER job of the same batch via its custom id (the only ids
14
+ * a client can know before the batch is applied). Order within the batch is
15
+ * deliberately irrelevant: the default-on auto-batcher groups concurrent
16
+ * add() calls in arbitrary order, so a child can legitimately precede its
17
+ * parent in the array, and the readiness layer (waitingDeps) resolves the
18
+ * chain once the whole batch is applied. Self-references are rejected, they
19
+ * can never resolve. jobIndex/completedJobs/depCompletions are hoisted out
20
+ * of the loop because PUSHB is the bulk hot path.
21
+ */
22
+ export function validatePushBatchJobs(jobs, ctx) {
23
+ const jobIndex = ctx.queueManager.getJobIndex();
24
+ const completedJobs = ctx.queueManager.getCompletedJobs();
25
+ const depCompletions = ctx.queueManager.getDepCompletions();
26
+ // Custom ids of ALL jobs in this batch: they become real job ids the
27
+ // moment the batch is applied. Allocated lazily (most batches have neither
28
+ // custom ids nor dependencies).
29
+ let batchIds = null;
30
+ for (const job of jobs) {
31
+ if (job.customId) {
32
+ batchIds ??= new Set();
33
+ batchIds.add(job.customId);
34
+ }
35
+ }
36
+ for (let i = 0; i < jobs.length; i++) {
37
+ const job = jobs[i];
38
+ const dataError = validateJobData(job.data);
39
+ if (dataError)
40
+ return `jobs[${i}]: ${dataError}`;
41
+ const optionsError = validateJobOptions({
42
+ priority: job.priority,
43
+ delay: job.delay,
44
+ timeout: job.timeout,
45
+ maxAttempts: job.maxAttempts,
46
+ backoff: job.backoff,
47
+ ttl: job.ttl,
48
+ });
49
+ if (optionsError)
50
+ return `jobs[${i}]: ${optionsError}`;
51
+ if (job.dependsOn && job.dependsOn.length > 0) {
52
+ for (const depId of job.dependsOn) {
53
+ const key = String(depId);
54
+ if (key === job.customId) {
55
+ return `jobs[${i}]: Job cannot depend on itself: ${key}`;
56
+ }
57
+ const exists = jobIndex.has(depId) ||
58
+ completedJobs.has(depId) ||
59
+ // removeOnComplete parents leave only a bare completion id behind;
60
+ // the gate must honor it exactly like PUSH does.
61
+ depCompletions.has(depId) ||
62
+ (batchIds?.has(key) ?? false);
63
+ if (!exists) {
64
+ return `jobs[${i}]: Dependency job not found: ${key}`;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return null;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.31",
3
+ "version": "2.8.32",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",