# bunqueue: full documentation > Concatenated full text of the bunqueue documentation for LLM grounding. bunqueue is a high-performance job queue for the Bun runtime, using in-memory storage by default, optional SQLite (WAL), or PostgreSQL 15-18 for multi-broker deployments, without Redis. It provides a BullMQ-compatible API and a native MCP server. Canonical site: https://bunqueue.dev. Curated index: https://bunqueue.dev/llms.txt --- # Free, Open Source Job Queue for Your Stack bunqueue is a free, MIT-licensed job queue for Node.js, Deno, Python, PHP, Go, Rust, Elixir and Bun. Run the included Bun-powered server yourself, or use embedded mode on Bun. No Redis required. URL: https://bunqueue.dev/ import { Tabs, TabItem } from '@astrojs/starlight/components'; import HomeHero from '@components/home/HomeHero.astro'; import HomeDetails from '@components/home/HomeDetails.astro'; import HomeDockerQuickstart from '@components/home/HomeDockerQuickstart.astro';

Your first job, end to end.

Choose how to run the queue. Both options are free, with the same core queue features.

1 Run your own server

In a terminal with Bun installed, start the included server. Keep it running.

```bash bunx bunqueue start --host 127.0.0.1 --data-path ./bunqueue.db ```
Client connection
127.0.0.1:6789 TCP
HTTP API
127.0.0.1:6790 HTTP
Storage
./bunqueue.db SQLite

This is a local process on your machine. The server is included in the MIT-licensed package.

Docker & deployment options

2 Connect your app and worker

Open another terminal. Install a client, save the example, then run it.

```bash npm install bunqueue-client ``` ```javascript title="jobs.mjs" import { Queue, Worker } from 'bunqueue-client'; const options = { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }; const queue = new Queue('emails', options); const worker = new Worker( 'emails', async (job) => { console.log('Processing:', job.data.to); return { sent: true }; }, options ); worker.on('error', (error) => console.error(error)); await queue.add('welcome', { to: 'hello@example.com' }); ``` ```bash node jobs.mjs ``` ```bash deno add npm:bunqueue-client ``` ```typescript title="jobs.ts" import { Queue, Worker } from 'bunqueue-client'; const options = { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }; const queue = new Queue<{ to: string }>('emails', options); const worker = new Worker<{ to: string }>( 'emails', async (job) => { console.log('Processing:', job.data.to); return { sent: true }; }, options ); worker.on('error', (error) => console.error(error)); await queue.add('welcome', { to: 'hello@example.com' }); ``` ```bash deno run --allow-net --allow-env --allow-sys=hostname jobs.ts ``` ```bash pip install bunqueue-client ``` ```python title="jobs.py" from bunqueue import Queue, Worker connection = {"host": "127.0.0.1", "port": 6789} queue = Queue("emails", **connection) queue.add("welcome", {"to": "hello@example.com"}) queue.close() def process(job): print("Processing:", job.data["to"]) return {"sent": True} Worker("emails", process, **connection).run() ``` ```bash python jobs.py ```

You should see {'Processing: hello@example.com'}. Your application and worker use the same queue name and TCP address.

PHP, Go, Rust, Elixir & all SDK guides

Run everything in your Bun process

Use embedded mode when your app and worker run together on Bun. The queue engine lives inside your application.

```bash bun add bunqueue ```
Runtime
Bun
Separate server
Not needed
Connection address
Not needed

Set embedded: true on both the queue and worker. The example keeps jobs in memory; configure SQLite persistence to recover work after a restart.

Add a job and process it

```typescript title="jobs.ts" import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue<{ to: string }>('emails', { embedded: true }); const worker = new Worker<{ to: string }>( 'emails', async (job) => { console.log('Processing:', job.data.to); return { sent: true }; }, { embedded: true } ); worker.on('error', (error) => console.error(error)); await queue.add('welcome', { to: 'hello@example.com' }); ``` ```bash bun jobs.ts ``` Read the full quickstart
--- # Introduction: A Free Job Queue for Your Language Use bunqueue from Node.js, Deno, Python, PHP, Go, Rust, Elixir or Bun. The free, MIT-licensed server runs on Bun; your application keeps its runtime. URL: https://bunqueue.dev/guide/introduction/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · introduction

A job queue for your language.

bunqueue is a free, MIT-licensed job queue for Node.js, Deno, Python, PHP, Go, Rust, Elixir and Bun. The name comes from the core engine, which runs on Bun. Your application and workers use their own runtime through an official client.

A job queue lets your app hand off slow work (sending emails, processing images, calling APIs) to run in the background instead of blocking a request. Choose the setup that matches your application: - **Node.js, Deno, Python, PHP, Go, Rust or Elixir:** run the included bunqueue server, install your language's client, then connect it to the server. Bun is required on the server, not in your application. Follow the [server and client setup](/#quickstart). - **Bun:** use the server in the same way, or run the queue and worker together with `embedded: true`. Follow the [embedded quickstart](/guide/quickstart/). Both options are free and include the queue features. No account, paid plan or Redis service is required. Use memory for ephemeral jobs, SQLite for one-process persistence, or PostgreSQL for several active brokers. ## See it in 10 lines ```typescript import { Queue, Worker } from 'bunqueue/client'; const storage = { embedded: true, dataPath: './data/bunq.db' } as const; const queue = new Queue('emails', storage); const worker = new Worker( 'emails', async (job) => { console.log('Sending to', job.data.to); return { sent: true }; }, storage ); await queue.add('welcome', { to: 'user@example.com' }); ``` Run it with `bun run app.ts`. No server: the queue runs embedded in your process. ```typescript import { Queue, Worker } from 'bunqueue-client'; const storage = { embedded: false } as const; const queue = new Queue('emails', storage); const worker = new Worker( 'emails', async (job) => { console.log('Sending to', job.data.to); return { sent: true }; }, storage ); await queue.add('welcome', { to: 'user@example.com' }); ``` Start the server once (`bunx bunqueue start`), then run the file with `node --experimental-strip-types app.ts` (Node 22+) or `deno run -A app.ts`. ```python from bunqueue import Queue, Worker queue = Queue("emails") queue.add("welcome", {"to": "user@example.com"}) def process(job): print("Sending to", job.data["to"]) return {"sent": True} Worker("emails", process).run() ``` Start the server once (`bunx bunqueue start`), then `python app.py`. ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('emails'); $queue->add('welcome', ['to' => 'user@example.com']); $worker = new Worker('emails', function (Bunqueue\Job $job) { return ['sent' => true]; }); $worker->run(); ``` Start the server once (`bunx bunqueue start`), then `php worker.php`. ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{}) defer queue.Close() queue.Add("welcome", map[string]any{"to": "user@example.com"}, nil) worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return map[string]any{"sent": true}, nil }, bunqueue.WorkerOptions{}) worker.Run() ``` Start the server once (`bunx bunqueue start`), then `go run .`. ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions}; let queue = Queue::new("emails", ConnectionOptions::default()); let data = Value::Map(vec![(Value::from("to"), Value::from("user@example.com"))]); queue.add("welcome", data, JobOptions::default())?; let worker = Worker::new("emails", |_job| Ok(Value::from(true)), WorkerOptions::default()); worker.run()?; ``` Start the server once (`bunx bunqueue start`), then `cargo run`. ```elixir queue = Bunqueue.queue("emails") {:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "user@example.com"}) worker = Bunqueue.Worker.new("emails", fn _job -> {:ok, %{sent: true}} end) Bunqueue.Worker.run(worker) ``` Start the server once (`bunx bunqueue start`), then `mix run app.exs`. That is a complete, working queue. The [Quick Start](/guide/quickstart/) walks through it step by step. ## Why bunqueue? - **Zero external infrastructure.** Add `dataPath` for one-file SQLite persistence, or omit it for memory-only queues. No Redis, no separate broker, and only one runtime dependency. - **Multi-broker when needed.** PostgreSQL 15–18 coordinates independent standalone servers with transactional claims and fenced leases; 18.6 is recommended. - **Native Bun.** Local persistence uses `bun:sqlite`; PostgreSQL uses Bun's built-in `SQL` client directly, with no ORM or third-party database driver. - **Familiar API.** If you know BullMQ, you already know most of bunqueue. - **Production features.** Retries with backoff (automatic waiting between retry attempts), a dead letter queue (a holding area for jobs that keep failing), cron scheduling, rate limiting, stall detection, and S3 backups. - **AI-agent ready.** A built-in MCP server with 73 tools lets agents like Claude add jobs, manage crons, and monitor queues via natural language. ## Two ways to run it | Comparison | Embedded | Server | | ------------------ | ---------------------------------------- | ----------------------------------------------- | | **What it is** | A library inside your process | A standalone `bunqueue` process | | **Best for** | Single-process apps, scripts, serverless | Multiple services sharing one queue | | **Setup** | Pass `embedded: true` | Run `bunqueue start`, then connect | | **Persistence** | Memory or SQLite via `dataPath` | Memory/SQLite, or PostgreSQL via URL | | **Broker scaling** | One process | One SQLite broker or several PostgreSQL brokers | **Embedded mode** means the queue lives inside your app, like using SQLite instead of Postgres: ```typescript import { Queue, Worker } from 'bunqueue/client'; // Queue and Worker must use the same embedded storage configuration. const storage = { embedded: true, dataPath: './data/bunq.db' } as const; const queue = new Queue('tasks', storage); const worker = new Worker( 'tasks', async (job) => { /* ... */ }, storage ); ``` **Server mode** runs bunqueue as a standalone service, and any number of apps connect to it over TCP. Use SQLite for one broker: ```bash bunqueue start --data-path ./data/queue.db ``` Or point independent servers at the same PostgreSQL namespace, with a unique broker ID for each process: ```bash BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@postgres:5432/bunqueue' \ BUNQUEUE_POSTGRES_NAMESPACE=production \ BUNQUEUE_BROKER_ID=broker-a \ bunqueue start ``` See [Storage backends](/guide/databases/) before choosing a production topology. ```typescript // No embedded option = connects to localhost:6789 const queue = new Queue('tasks'); const worker = new Worker('tasks', async (job) => { /* ... */ }); ``` ```typescript // No embedded option = connects to localhost:6789 const queue = new Queue('tasks'); const worker = new Worker('tasks', async (job) => { /* ... */ }); ``` ```python from bunqueue import Queue, Worker queue = Queue("tasks") # connects to localhost:6789 def process(job): ... Worker("tasks", process).run() ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('tasks'); // connects to localhost:6789 $worker = new Worker('tasks', function (Bunqueue\Job $job) { // ... }); $worker->run(); ``` ```go queue := bunqueue.NewQueue("tasks", bunqueue.Options{}) // localhost:6789 worker := bunqueue.NewWorker("tasks", func(job *bunqueue.Job) (any, error) { // ... return nil, nil }, bunqueue.WorkerOptions{}) worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, Queue, Value, Worker, WorkerOptions}; let queue = Queue::new("tasks", ConnectionOptions::default()); // localhost:6789 let worker = Worker::new("tasks", |_job| Ok(Value::Nil), WorkerOptions::default()); worker.run()?; ``` ```elixir queue = Bunqueue.queue("tasks") # connects to localhost:6789 worker = Bunqueue.Worker.new("tasks", fn _job -> {:ok, %{}} end) Bunqueue.Worker.run(worker) ``` Server mode also unlocks clients in other runtimes: Node.js, Deno, Python, PHP, Go, Rust, Elixir, and Cloudflare Workers via the [client SDKs](/guide/sdks/). ## Compared to BullMQ | Feature | bunqueue | BullMQ | | ------------------------------------- | -------------- | ------------ | | Runtime | Bun | Node.js | | Storage | Memory/SQLite; optional PostgreSQL | Redis | | External deps | None by default; PostgreSQL when selected | Redis server | | Priorities, delays, retries, cron | Yes | Yes | | Rate limiting, stall detection, flows | Yes | Yes | | Pro-style groups and processor batches | Yes | Pro package | | Pro telemetry / NestJS integration | No | Pro package | | Advanced DLQ (auto-retry, filters) | Yes | Basic | | S3 backups | SQLite mode | No | | MCP server for AI agents | Yes (73 tools) | No | | Built-in workflow engine | Yes | No | Migrating? The API is intentionally close to BullMQ, see the [migration guide](/guide/migration/). ## Beyond jobs: workflows For multi-step processes (validate an order, charge, notify, ship) bunqueue ships a workflow engine with retries, parallel steps, branching, rollback on failure, and human-in-the-loop signals: ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const flow = new Workflow('order') .step('validate', async (ctx) => ({ ok: true })) .step('charge', async (ctx) => ({ txId: 'tx_123' }), { retry: 3 }) .waitFor('approval') .step('ship', async (ctx) => ({ shipped: true })); ``` No Temporal, no extra service. See the [Workflow Engine guide](/guide/workflow/). ## Next steps - [Installation](/guide/installation/), get bunqueue installed - [Quick Start](/guide/quickstart/), build your first queue in a minute - [Server Mode](/guide/server/), run bunqueue as a standalone service - [MCP Server](/guide/mcp/), connect AI agents to your queues --- # Install bunqueue: Server and Clients for Your Runtime Install the free bunqueue server and the client for Node.js, Deno, Python, PHP, Go, Rust, Elixir or Bun. Bun is required by the engine; network clients use their own runtime. URL: https://bunqueue.dev/guide/installation/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · installation

Install for your runtime.

The free bunqueue server runs on Bun. Your application uses the client for its own language. Bun applications can also embed the queue directly. The server, clients and queue features are included under MIT.

## Requirements - The `bunqueue` server and embedded runtime require [Bun](https://bun.sh) v1.4.0 or later when run from the package. Standalone executables and Docker images already include the runtime. - External Node.js, Deno, Python, PHP, Go, Rust, and Elixir clients need their SDK's documented runtime plus a reachable Bun-powered bunqueue server; they do not require Bun in the client process. ## Install ```bash bun add bunqueue ``` That is it. The package includes the client library, standalone server, and CLI. `msgpackr` is its only direct runtime dependency; SQLite, PostgreSQL connectivity, cron parsing, HTTP, WebSocket, and S3 use Bun's native APIs. ```bash npm install bunqueue-client # Node.js 20+ deno add npm:bunqueue-client # Deno 2+ ``` ```bash pip install bunqueue-client ``` ```bash composer require bunqueue/client ``` ```bash go get github.com/egeominotti/bunqueue/sdk/go ``` ```bash cargo add bunqueue-client ``` ```elixir # Hex release upcoming; use sdk/elixir as a path dependency today {:bunqueue_client, path: "../bunqueue/sdk/elixir"} ``` _The Bun `bunqueue` package bundles the client, the server, and the CLI. Every other SDK is a client only: it connects to a bunqueue server, started once with `bunx bunqueue start` (see [SDKs](/guide/sdks/) and [Server Mode](/guide/server/))._ ## Verify it works Save this as `test.ts` and run `bun run test.ts`: ```typescript import { Queue, Worker } from 'bunqueue/client'; // Both Queue and Worker must have embedded: true const queue = new Queue('test', { embedded: true }); const worker = new Worker( 'test', async (job) => { console.log('Processing:', job.data); return { success: true }; }, { embedded: true } ); await queue.add('hello', { message: 'bunqueue is working!' }); ``` Start a server (`bunx bunqueue start`), save this as `test.ts`, then run `node --experimental-strip-types test.ts` (Node 22+) or `deno run -A test.ts`: ```typescript import { Queue, Worker } from 'bunqueue-client'; // Both Queue and Worker must have embedded: false const queue = new Queue('test', { embedded: false }); const worker = new Worker( 'test', async (job) => { console.log('Processing:', job.data); return { success: true }; }, { embedded: false } ); await queue.add('hello', { message: 'bunqueue is working!' }); ``` Start a server (`bunx bunqueue start`), then run `python test.py`: ```python from bunqueue import Queue, Worker queue = Queue("test") # connects to localhost:6789 queue.add("hello", {"message": "bunqueue is working!"}) def process(job): print("Processing:", job.data) return {"success": True} Worker("test", process).run() ``` Start a server (`bunx bunqueue start`), then run `php test.php`: ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('test'); // connects to localhost:6789 $queue->add('hello', ['message' => 'bunqueue is working!']); $worker = new Worker('test', function (Bunqueue\Job $job) { var_dump($job->data()); return ['success' => true]; }); $worker->run(); ``` Start a server (`bunx bunqueue start`), then `go run .`: ```go queue := bunqueue.NewQueue("test", bunqueue.Options{}) // localhost:6789 defer queue.Close() queue.Add("hello", map[string]any{"message": "bunqueue is working!"}, nil) worker := bunqueue.NewWorker("test", func(job *bunqueue.Job) (any, error) { fmt.Println("Processing:", job.Data()) return map[string]any{"success": true}, nil }, bunqueue.WorkerOptions{}) worker.Run() ``` Start a server (`bunx bunqueue start`), then `cargo run`: ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions}; let queue = Queue::new("test", ConnectionOptions::default()); // localhost:6789 let data = Value::Map(vec![(Value::from("message"), Value::from("bunqueue is working!"))]); queue.add("hello", data, JobOptions::default())?; let worker = Worker::new("test", |job| { println!("Processing: {:?}", job.data()); Ok(Value::from(true)) }, WorkerOptions::default()); worker.run()?; ``` Start a server (`bunx bunqueue start`), then `mix run test.exs`: ```elixir queue = Bunqueue.queue("test") # connects to localhost:6789 {:ok, _job} = Bunqueue.Queue.add(queue, "hello", %{message: "bunqueue is working!"}) worker = Bunqueue.Worker.new("test", fn job -> IO.inspect(job.data, label: "Processing") {:ok, %{success: true}} end) Bunqueue.Worker.run(worker) ``` You should see `Processing: { message: "bunqueue is working!" }`. Next: the [Quick Start](/guide/quickstart/) builds on this. To check the server and CLI: ```bash bunqueue --version bunqueue start ``` ## Docker (runtime included) From 2.9.5, release images are available on Docker Hub as `egeominotti/bunqueue` and on GHCR as `ghcr.io/egeominotti/bunqueue`. Both provide Linux amd64 and arm64 images under the same tag; Docker selects the appropriate architecture. ```bash docker run -d --name bunqueue \ -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ egeominotti/bunqueue:2.9.5 curl http://localhost:6790/health ``` The named volume persists SQLite data at `/app/data`. TCP clients connect to port 6789; HTTP endpoints use port 6790. Use a version tag or digest for deployments; `latest` follows the most recently published release. ## Single binary (no Bun required) Each release ships self-contained executables, useful on servers and edge devices (Raspberry Pi, ARM64 boxes) where you don't want to install a runtime: Starting with 2.9.5, choose one of eight archives from [GitHub releases](https://github.com/egeominotti/bunqueue/releases): | Operating system | Architecture | Archive | |---|---|---| | Linux (glibc) | x64 | `bunqueue-linux-x64.tar.gz` | | Linux (glibc) | arm64 | `bunqueue-linux-arm64.tar.gz` | | Linux (musl / Alpine) | x64 | `bunqueue-linux-x64-musl.tar.gz` | | Linux (musl / Alpine) | arm64 | `bunqueue-linux-arm64-musl.tar.gz` | | macOS | x64 / Intel | `bunqueue-darwin-x64.tar.gz` | | macOS | arm64 / Apple Silicon | `bunqueue-darwin-arm64.tar.gz` | | Windows | x64 | `bunqueue-windows-x64.zip` | | Windows | arm64 | `bunqueue-windows-arm64.zip` | For example, on Linux arm64 with glibc: ```bash curl -fsSLO https://github.com/egeominotti/bunqueue/releases/latest/download/bunqueue-linux-arm64.tar.gz tar -xzf bunqueue-linux-arm64.tar.gz sudo mv bunqueue-linux-arm64 /usr/local/bin/bunqueue bunqueue start --data-path /var/lib/bunqueue/queue.db ``` A `SHA256SUMS` file is attached to every release for checksum verification. Download it from the same release as your archive. On Windows, extract the ZIP and run `bunqueue-windows-x64.exe` or `bunqueue-windows-arm64.exe`. The binary is the full server + CLI. For the client SDK in your app code you still install the package (`bun add bunqueue`). ## Install from source ```bash git clone https://github.com/egeominotti/bunqueue.git cd bunqueue bun install bun run build ``` ## TypeScript support bunqueue is written in TypeScript and ships full type definitions: ```typescript import type { Job, JobOptions, WorkerOptions, StallConfig, DlqConfig, DlqEntry, } from 'bunqueue/client'; ``` :::tip[Next Steps] - [Quick Start](/guide/quickstart/), build your first queue - [Introduction](/guide/introduction/), what bunqueue is and when to use it - [MCP Server](/guide/mcp/), let AI agents manage your queues ::: --- # Quick Start: Your First Bun Job Queue in Minutes Get started with bunqueue in minutes. Create queues, add jobs, process them with Workers, and choose SQLite or PostgreSQL persistence. URL: https://bunqueue.dev/guide/quickstart/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · quickstart

Working queue in a minute.

Create a queue, add jobs, process them with a Worker, and turn on persistence. On Bun everything runs embedded in a single process with zero configuration; from any other language, start the server once and connect.

## The smallest working queue Install the client for your runtime (see the [SDK guide](/guide/sdks/)), save the snippet as a file, run it. On Bun the queue runs embedded in your process; in every other language, start the server once with `bunx bunqueue start` first. ```typescript import { Queue, Worker } from 'bunqueue/client'; // The queue: where you put jobs const queue = new Queue('emails', { embedded: true }); // The worker: pulls jobs and runs your function on each one const worker = new Worker( 'emails', async (job) => { console.log(`Sending "${job.data.subject}" to ${job.data.to}`); return { sent: true }; }, { embedded: true } ); // Add a job await queue.add('welcome', { to: 'user@example.com', subject: 'Welcome!' }); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; // The queue: where you put jobs const queue = new Queue('emails', { embedded: false }); // The worker: pulls jobs and runs your function on each one const worker = new Worker( 'emails', async (job) => { console.log(`Sending "${job.data.subject}" to ${job.data.to}`); return { sent: true }; }, { embedded: false } ); // Add a job await queue.add('welcome', { to: 'user@example.com', subject: 'Welcome!' }); ``` ```python from bunqueue import Queue, Worker # The queue: where you put jobs (connects to localhost:6789 by default) queue = Queue("emails") queue.add("welcome", {"to": "user@example.com", "subject": "Welcome!"}) # The worker: pulls jobs and runs your function on each one def process(job): print(f"Sending {job.data['subject']} to {job.data['to']}") return {"sent": True} worker = Worker("emails", process) worker.run() ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; // The queue: where you put jobs (connects to localhost:6789 by default) $queue = new Queue('emails'); $queue->add('welcome', ['to' => 'user@example.com', 'subject' => 'Welcome!']); // The worker: pulls jobs and runs your function on each one $worker = new Worker('emails', function (Bunqueue\Job $job) { $data = $job->data(); echo "Sending {$data['subject']} to {$data['to']}\n"; return ['sent' => true]; }); $worker->run(); ``` ```go // The queue: where you put jobs (connects to localhost:6789 by default) queue := bunqueue.NewQueue("emails", bunqueue.Options{}) defer queue.Close() queue.Add("welcome", map[string]any{ "to": "user@example.com", "subject": "Welcome!", }, nil) // The worker: pulls jobs and runs your function on each one worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { data := job.Data() fmt.Printf("Sending %v to %v\n", data["subject"], data["to"]) return map[string]any{"sent": true}, nil }, bunqueue.WorkerOptions{}) worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions}; // The queue: where you put jobs (connects to localhost:6789 by default) let queue = Queue::new("emails", ConnectionOptions::default()); let data = Value::Map(vec![ (Value::from("to"), Value::from("user@example.com")), (Value::from("subject"), Value::from("Welcome!")), ]); queue.add("welcome", data, JobOptions::default())?; // The worker: pulls jobs and runs your function on each one let worker = Worker::new( "emails", |job| { println!("Sending {:?}", job.data()); Ok(Value::from(true)) }, WorkerOptions::default(), ); worker.run()?; ``` ```elixir # The queue: where you put jobs (connects to localhost:6789 by default) queue = Bunqueue.queue("emails") {:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "user@example.com", subject: "Welcome!"}) # The worker: pulls jobs and runs your function on each one worker = Bunqueue.Worker.new("emails", fn job -> IO.puts("Sending #{job.data["subject"]} to #{job.data["to"]}") {:ok, %{sent: true}} end) Bunqueue.Worker.run(worker) ``` The snippets above are the body of a program, not a whole file: drop each one into your project's entry point, then run it. The worker keeps running until you stop it with `Ctrl-C`: ```bash bun run app.ts # Bun node app.js # Node.js (needs "type": "module" in package.json) deno run -A app.ts # Deno python app.py # Python php app.php # PHP go run . # Go cargo run # Rust mix run --no-halt # Elixir ``` The worker prints one line per job: ```text Sending "Welcome!" to user@example.com # Bun, Node.js / Deno Sending Welcome! to user@example.com # Python, PHP, Go, Elixir Sending Map([(String(Utf8String { s: Ok("to") }), ...)]) # Rust: `{:?}` on the decoded msgpack value ``` On Bun, `embedded: true` means the queue runs inside your process, no server needed. Every other client talks TCP to the server (`bunx bunqueue start`) and defaults to `localhost:6789`. :::danger[The one mistake everyone makes] On Bun, `Queue` and `Worker` must use the **same mode**. If one has `embedded: true` and the other doesn't, the other tries to connect to a TCP server that isn't running and fails with a "Command timeout" error. ```typescript // ✅ Both embedded const queue = new Queue('tasks', { embedded: true }); const worker = new Worker('tasks', handler, { embedded: true }); // ❌ Mixed modes = timeout error const queue = new Queue('tasks', { embedded: true }); const worker = new Worker('tasks', handler); // Missing embedded: true! ``` ::: ## Add jobs with options ```typescript // Typed queue: job.data is type-checked interface EmailJob { to: string; subject: string; } const emailQueue = new Queue('emails', { embedded: true }); // Priority, delay, retries await emailQueue.add( 'send-email', { to: 'a@test.com', subject: 'Hi' }, { priority: 10, // Higher = processed first delay: 5000, // Wait 5 seconds before processing attempts: 3, // Retry up to 3 times if the processor throws backoff: 1000, // Wait 1 second between retries (grows on each attempt) } ); // Many jobs at once (one optimized batch) await emailQueue.addBulk([ { name: 'send-email', data: { to: 'a@test.com', subject: 'Hi' } }, { name: 'send-email', data: { to: 'b@test.com', subject: 'Hi' } }, ]); ``` ```typescript // Typed queue: job.data is type-checked interface EmailJob { to: string; subject: string; } const emailQueue = new Queue('emails', { embedded: false }); // Priority, delay, retries await emailQueue.add( 'send-email', { to: 'a@test.com', subject: 'Hi' }, { priority: 10, // Higher = processed first delay: 5000, // Wait 5 seconds before processing attempts: 3, // Retry up to 3 times if the processor throws backoff: 1000, // Wait 1 second between retries (grows on each attempt) } ); // Many jobs at once (one optimized batch) await emailQueue.addBulk([ { name: 'send-email', data: { to: 'a@test.com', subject: 'Hi' } }, { name: 'send-email', data: { to: 'b@test.com', subject: 'Hi' } }, ]); ``` ```python # Priority, delay, retries queue.add("send-email", {"to": "a@test.com", "subject": "Hi"}, priority=10, # Higher = processed first delay=5000, # Wait 5 seconds before processing attempts=3, # Retry up to 3 times if the processor raises backoff=1000) # Wait 1 second between retries (grows on each attempt) # Many jobs at once (one optimized batch) queue.add_bulk([ {"name": "send-email", "data": {"to": "a@test.com", "subject": "Hi"}}, {"name": "send-email", "data": {"to": "b@test.com", "subject": "Hi"}}, ]) ``` ```php // Priority, delay, retries $queue->add('send-email', ['to' => 'a@test.com', 'subject' => 'Hi'], [ 'priority' => 10, // Higher = processed first 'delay' => 5000, // Wait 5 seconds before processing 'attempts' => 3, // Retry up to 3 times if the processor throws 'backoff' => 1000, // Wait 1 second between retries (grows on each attempt) ]); // Many jobs at once (one optimized batch) $queue->addBulk([ ['name' => 'send-email', 'data' => ['to' => 'a@test.com', 'subject' => 'Hi']], ['name' => 'send-email', 'data' => ['to' => 'b@test.com', 'subject' => 'Hi']], ]); ``` ```go // Priority, delay, retries queue.Add("send-email", map[string]any{"to": "a@test.com", "subject": "Hi"}, bunqueue.JobOptions{ "priority": 10, // Higher = processed first "delay": 5000, // Wait 5 seconds before processing "attempts": 3, // Retry up to 3 times if the processor errors "backoff": 1000, // Wait 1 second between retries (grows on each attempt) }) // Many jobs at once (one optimized batch) ids, err := queue.AddBulk([]bunqueue.BulkEntry{ {Name: "send-email", Data: map[string]any{"to": "a@test.com", "subject": "Hi"}}, {Name: "send-email", Data: map[string]any{"to": "b@test.com", "subject": "Hi"}}, }) ``` ```rust use bunqueue_client::{Backoff, BulkEntry, JobOptions, Value}; let data = Value::Map(vec![ (Value::from("to"), Value::from("a@test.com")), (Value::from("subject"), Value::from("Hi")), ]); // Priority, delay, retries queue.add("send-email", data.clone(), JobOptions { priority: Some(10), // Higher = processed first delay: Some(5000), // Wait 5 seconds before processing attempts: Some(3), // Retry up to 3 times on failure backoff: Some(Backoff::Milliseconds(1000)), // Wait 1 second between retries ..Default::default() })?; // Many jobs at once (one optimized batch) queue.add_bulk(vec![ BulkEntry { name: "send-email".into(), data: data.clone(), options: JobOptions::default() }, BulkEntry { name: "send-email".into(), data, options: JobOptions::default() }, ])?; ``` ```elixir # Priority, delay, retries {:ok, _job} = Bunqueue.Queue.add(queue, "send-email", %{to: "a@test.com", subject: "Hi"}, # Higher = processed first; wait 5s; retry 3 times; 1s between retries priority: 10, delay: 5000, attempts: 3, backoff: 1000 ) # Many jobs at once (one optimized batch) {:ok, _ids} = Bunqueue.Queue.add_bulk(queue, [ %{name: "send-email", data: %{to: "a@test.com", subject: "Hi"}}, %{name: "send-email", data: %{to: "b@test.com", subject: "Hi"}} ]) ``` All options are in the [Queue guide](/guide/queue/). ## Do more inside the processor ```typescript const worker = new Worker( 'emails', async (job) => { await job.updateProgress(50, 'Sending email...'); // Report progress await sendEmail(job.data); // Do the work await job.log('Email sent successfully'); // Attach a log line return { sent: true, timestamp: Date.now() }; // Result, stored and queryable }, { embedded: true, concurrency: 5, // Process 5 jobs in parallel } ); ``` ```typescript const worker = new Worker( 'emails', async (job) => { await job.updateProgress(50, 'Sending email...'); // Report progress await sendEmail(job.data); // Do the work await job.log('Email sent successfully'); // Attach a log line return { sent: true, timestamp: Date.now() }; // Result, stored and queryable }, { embedded: false, concurrency: 5, // Process 5 jobs in parallel } ); ``` ```python def process(job): job.update_progress(50, "Sending email...") # Report progress send_email(job.data) # Do the work job.log("Email sent successfully") # Attach a log line return {"sent": True} # Result, stored and queryable Worker("emails", process, concurrency=5).run() # Process 5 jobs in parallel ``` ```php $worker = new Worker('emails', function (Bunqueue\Job $job) { $job->updateProgress(50, 'Sending email...'); // Report progress sendEmail($job->data()); // Do the work $job->log('Email sent successfully'); // Attach a log line return ['sent' => true]; // Result, stored and queryable }); $worker->run(); // The PHP worker is sequential by design (one job at a time) ``` ```go worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { job.UpdateProgress(50, "Sending email...") // Report progress if err := sendEmail(job.Data()); err != nil { // Do the work return nil, err } job.Log("Email sent successfully", "info") // Attach a log line return map[string]any{"sent": true}, nil // Result, stored and queryable }, bunqueue.WorkerOptions{Concurrency: 5}) // Process 5 jobs in parallel ``` ```rust use bunqueue_client::{ProcessError, Value, Worker, WorkerOptions}; let worker = Worker::new( "emails", |job| { let _ = job.update_progress(50.0, Some("Sending email...")); // Report progress send_email(job.data()) // Do the work .map_err(|e| ProcessError::retryable(e.to_string()))?; let _ = job.log("Email sent successfully", None); // Attach a log line Ok(Value::from(true)) // Result, stored }, WorkerOptions { concurrency: 5, ..Default::default() }, // 5 jobs in parallel ); ``` ```elixir worker = Bunqueue.Worker.new("emails", fn job -> Bunqueue.Job.update_progress(job, 50, "Sending email...") # Report progress send_email(job.data) # Do the work Bunqueue.Job.log(job, "Email sent successfully") # Attach a log line {:ok, %{sent: true}} # Result, stored end, concurrency: 5) # 5 jobs in parallel ``` ## React to events ```typescript worker.on('completed', (job, result) => { console.log(`Job ${job.id} completed:`, result); }); worker.on('failed', (job, error) => { console.error(`Job ${job.id} failed:`, error.message); }); worker.on('progress', (job, progress) => { console.log(`Job ${job.id} progress: ${progress}%`); }); ``` ```typescript worker.on('completed', (job, result) => { console.log(`Job ${job.id} completed:`, result); }); worker.on('failed', (job, error) => { console.error(`Job ${job.id} failed:`, error.message); }); worker.on('progress', (job, progress) => { console.log(`Job ${job.id} progress: ${progress}%`); }); ``` ```python worker.on("completed", lambda job, result: print(f"Job {job.id} completed: {result}")) worker.on("failed", lambda job, err: print(f"Job {job.id} failed: {err}")) worker.on("progress", lambda job, progress: print(f"Job {job.id} progress: {progress}%")) ``` ```php $worker->on('completed', function ($job, $result) { echo "Job {$job->id()} completed\n"; }); $worker->on('failed', function ($job, $err) { echo "Job {$job->id()} failed\n"; }); // The PHP worker has no 'progress' event; query progress via the Queue API. ``` ```go worker.On("completed", func(args ...any) { job := args[0].(*bunqueue.Job) fmt.Printf("Job %s completed\n", job.ID()) }) worker.On("failed", func(args ...any) { job := args[0].(*bunqueue.Job) fmt.Printf("Job %s failed\n", job.ID()) }) // The Go worker has no "progress" event; query progress via the Queue API. ``` ```rust // The Rust worker has no event emitter: handle each outcome in the processor, // and use the connection telemetry callback for transport lifecycle events. let worker = Worker::new( "emails", |job| match send_email(job.data()) { Ok(_) => { println!("Job {} completed", job.id()); Ok(Value::from(true)) } Err(e) => { eprintln!("Job {} failed: {e}", job.id()); Err(ProcessError::retryable(e.to_string())) } }, WorkerOptions::default(), ); ``` ```elixir # The Elixir worker has no event emitter: handle each outcome in the handler, # and use the connection `:event_handler` callback for transport lifecycle events. worker = Bunqueue.Worker.new("emails", fn job -> case send_email(job.data) do :ok -> IO.puts("Job #{job.id} completed") {:ok, %{sent: true}} {:error, reason} -> IO.puts("Job #{job.id} failed: #{inspect(reason)}") {:error, reason} end end) ``` The full event list is in the [Worker guide](/guide/worker/). ## Turn on persistence Without a data path, jobs live in memory and disappear on restart. Point bunqueue at a SQLite file to survive restarts: ```typescript // Option 1: dataPath option (recommended) const queue = new Queue('tasks', { embedded: true, dataPath: './data/bunqueue.db' }); const worker = new Worker('tasks', processor, { embedded: true, dataPath: './data/bunqueue.db' }); // Option 2: environment variable // BUNQUEUE_DATA_PATH=./data/bunqueue.db bun run app.ts ``` Every embedded `Queue` and `Worker` in the process shares one database. Naming the same `dataPath` again is fine; naming a *different* one throws instead of silently opening a second database. **In server mode** persistence is configured once on the server, and no client in any language changes: ```bash bunx bunqueue start --data-path ./data/bunq.db ``` _`dataPath` is an embedded (Bun) option only. `BUNQUEUE_DATA_PATH` is the canonical variable; `BQ_DATA_PATH`, `DATA_PATH` and `SQLITE_PATH` are still read, in that order, as fallbacks._ ## Shut down cleanly ```typescript import { shutdownManager } from 'bunqueue/client'; process.on('SIGINT', async () => { await worker.close(); // Finish active jobs shutdownManager(); // Flush pending writes, close SQLite process.exit(0); }); ``` ```typescript import { shutdownManager } from 'bunqueue-client'; process.on('SIGINT', async () => { await worker.close(); // Finish active jobs process.exit(0); }); ``` ```python worker.close() # Stop pulling, wait for in-flight jobs to drain queue.close() # Close the connection ``` ```php $worker->installSignalHandlers(); // SIGTERM / SIGINT -> graceful stop $worker->run(); // Returns after the in-flight job finishes $worker->close(); // Unregister and close the connection ``` ```go worker.Stop() // Stop pulling; in-flight jobs finish worker.Close() // Unregister and close the connection queue.Close() ``` ```rust worker.stop(); // Stop pulling; in-flight jobs finish worker.close(); // Unregister and close the connection queue.close(); ``` ```elixir # Idempotent drain barrier: waits for active handlers, then unregisters and closes Bunqueue.Worker.stop(worker) ``` ## Need more than one process? The Bun examples above run in a single process. When multiple services need to share one queue, run bunqueue as a standalone server instead: | Comparison | Embedded mode | Server mode | | ------------ | ------------------------------- | --------------------------------------------------------------------- | | **Best for** | Single-process apps, serverless | Multi-process, microservices | | **Setup** | `embedded: true` | Run `bunx bunqueue start`, drop the option | | **Clients** | Bun only (in process) | Node.js, Deno, Bun, Python, PHP, Go, Rust, Elixir, Cloudflare Workers | See the [Server guide](/guide/server/). All six official client SDKs speak the same protocol against the same queues, see the [SDK guide](/guide/sdks/). The server uses memory/SQLite by default; for several active brokers sharing one queue, configure the [PostgreSQL 15–18 backend](/guide/databases/); 18.6 is recommended. ## Where to go next **Less boilerplate.** `Bunqueue` (Simple Mode) wraps Queue + Worker in one object with routes, middleware, and cron: ```typescript import { Bunqueue } from 'bunqueue/client'; const app = new Bunqueue('notifications', { embedded: true, routes: { 'send-email': async (job) => ({ sent: true }), 'send-sms': async (job) => ({ sent: true }), }, concurrency: 10, }); await app.add('send-email', { to: 'alice@example.com' }); await app.cron('daily-report', '0 9 * * *', { type: 'summary' }); ``` ```typescript import { Bunqueue } from 'bunqueue-client'; const app = new Bunqueue('notifications', { embedded: false, routes: { 'send-email': async (job) => ({ sent: true }), 'send-sms': async (job) => ({ sent: true }), }, concurrency: 10, }); await app.add('send-email', { to: 'alice@example.com' }); await app.cron('daily-report', '0 9 * * *', { type: 'summary' }); ``` ```python from bunqueue import Bunqueue app = Bunqueue( "notifications", routes={ "send-email": lambda job: {"sent": True}, "send-sms": lambda job: {"sent": True}, }, concurrency=10, ) app.add("send-email", {"to": "alice@example.com"}) app.cron("daily-report", "0 9 * * *", {"type": "summary"}) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly, and route on `$job->name()` inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly, and route on `job.Name()` inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly, and route on `job.name()` inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly, and route on `job.name` inside the handler. See the [Simple Mode guide](/guide/simple-mode/). **Watch it live.** The web dashboard shows queues, jobs, failures, crons, and workers. One command: ```bash bunx bunqueue-dashboard ``` Try the [live demo](https://egeominotti.github.io/bunqueue-dashboard/) without installing anything. **Connect AI agents.** bunqueue ships an MCP server with 73 tools, so agents like Claude can add jobs, manage crons, and monitor queues via natural language: ```bash bun add -g bunqueue # provides the bunqueue-mcp binary bun add -g @modelcontextprotocol/sdk # required by the MCP server only claude mcp add bunqueue -- bunx --package=bunqueue bunqueue-mcp ``` Setup for Claude Desktop, Cursor, and Windsurf is in the [MCP guide](/guide/mcp/). **Orchestrate multi-step processes.** The built-in workflow engine (Bun runtime) handles branching, parallel steps, rollback on failure, and human approvals: ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const flow = new Workflow('order') .step('validate', async (ctx) => ({ ok: true })) .step('charge', async (ctx) => ({ txId: 'tx_123' })) .waitFor('manager-approval') // Pauses until you send a signal .step('ship', async (ctx) => ({ shipped: true })); const engine = new Engine({ embedded: true }); engine.register(flow); await engine.start('order', { orderId: 'ORD-1' }); ``` See the [Workflow Engine guide](/guide/workflow/). ## Next steps - [Queue API](/guide/queue/), all job options and queue operations - [Worker API](/guide/worker/), concurrency, events, error handling - [Server Mode](/guide/server/), run bunqueue as a standalone server - [Client SDKs](/guide/sdks/), use the queue from Node.js, Deno, Python, PHP, Go, Rust, Elixir - [Code Examples & Recipes](/examples/), complete examples --- # MCP Server: Let AI Agents Drive the Queue Connect Claude, Cursor or any MCP client to bunqueue with one command. Agents get 73 tools to add jobs, schedule crons, retry failures and monitor queues. URL: https://bunqueue.dev/guide/mcp/ import { Aside } from '@astrojs/starlight/components'; import McpArchitecture from '../../../components/McpArchitecture.astro'; import HttpHandlerFlow from '../../../components/HttpHandlerFlow.astro';
guide · mcp

Your queue, driven by agents.

bunqueue ships a built-in MCP server, so AI agents like Claude and Cursor can add jobs, schedule crons and monitor queues by talking to it. One command to connect, no code to write.

MCP (Model Context Protocol) is the open standard that lets AI agents call external tools. bunqueue exposes its whole queue surface as 73 MCP tools, so anything you can do with the SDK or CLI, an agent can do from a chat prompt. ## Quick start Three commands. `bunqueue-mcp` is a binary bundled inside the `bunqueue` package, and the MCP SDK is an optional peer dependency you install once: ```bash bun add -g bunqueue # provides the bunqueue-mcp binary bun add -g @modelcontextprotocol/sdk # required by the MCP server only claude mcp add bunqueue -- bunx --package=bunqueue bunqueue-mcp ``` Then just ask your agent: > "Add a job to the emails queue" It works immediately. No server to start, no database to configure. The queue runs inside the MCP process (embedded mode); set `DATA_PATH` in the server's `env` to persist jobs to a SQLite file, otherwise state lives in memory for the session. ## What can an agent do? Some real prompts and the tools they trigger: | You say | The agent calls | |---------|-----------------| | "Add 3 notification jobs: push, email, sms" | `bunqueue_add_jobs_bulk` | | "Schedule session cleanup every hour" | `bunqueue_add_cron` | | "Rate limit notifications to 50 per second" | `bunqueue_set_rate_limit` | | "Why are jobs stuck? Check the emails queue" | `bunqueue_debug_queue` prompt, then stats and DLQ tools | | "Retry everything in the dead letter queue" | `bunqueue_retry_dlq` | | "Create a pipeline: validate payment, send receipt, update inventory" | `bunqueue_add_flow_chain` | ## Setup for other clients Claude Desktop, Cursor and Windsurf all use the same JSON block. Config file locations: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\Claude\claude_desktop_config.json` (Windows), `~/.config/Claude/claude_desktop_config.json` (Linux), or your editor's MCP settings. ```json { "mcpServers": { "bunqueue": { "command": "bunx", "args": ["--package=bunqueue", "bunqueue-mcp"] } } } ``` `bunx --package=bunqueue bunqueue-mcp` resolves the bundled binary straight from the `bunqueue` package, so it works even without a global install. It does not auto-install the optional `@modelcontextprotocol/sdk` peer dependency, install that yourself once. ## Two modes: embedded and TCP | | Embedded (default) | TCP (remote) | | --- | --- | --- | | **How it works** | Queue runs inside the MCP process, in memory or direct SQLite | MCP server forwards to a running bunqueue server | | **Setup** | Zero config | Set `BUNQUEUE_MODE=tcp` plus host/port | | **Best for** | Local dev, single machine | Production queues shared by real workers | | **Data** | In-memory, or a SQLite file via `DATA_PATH` | The remote server owns memory/SQLite or PostgreSQL storage | To manage a production server, point the MCP server at it: ```json { "mcpServers": { "bunqueue": { "command": "bunx", "args": ["--package=bunqueue", "bunqueue-mcp"], "env": { "BUNQUEUE_MODE": "tcp", "BUNQUEUE_HOST": "your-server.com", "BUNQUEUE_PORT": "6789", "BUNQUEUE_TOKEN": "your-auth-token" } } } } ``` All 73 tools work in both modes. ## HTTP Handlers Agents can schedule jobs, but they cannot run a long-lived worker process to execute them. HTTP handlers close that gap: the agent registers a URL on a queue, and the MCP server spawns an embedded worker that pulls each job and calls that URL. The HTTP response becomes the job result; a non-2xx response or timeout fails the job into the normal retry and dead letter flow. ```bash $ claude > Register a GET handler on "meteo" that calls the OpenWeather API ✓ bunqueue_register_handler Worker started. Jobs in "meteo" will be processed via GET. > Create a cron that pushes a job every 10 seconds ✓ bunqueue_add_cron name: "check-meteo", repeatEvery: 10000 # Every 10s: cron creates a job → worker calls the API → response saved as result ``` `bunqueue_register_handler` parameters: | Parameter | Required | Description | |-----------|----------|-------------| | `queue` | Yes | Queue to attach the handler to | | `url` | Yes | Endpoint to call for each job | | `method` | Yes | `GET`, `POST`, `PUT`, or `DELETE` | | `headers` | No | Custom HTTP headers (e.g. `Authorization`) | | `body` | No | Fixed body for POST/PUT; defaults to the job's data as JSON | | `timeoutMs` | No | Request timeout (default 30000, range 1000 to 120000) | Use handlers when processing is just an HTTP call (API polling, webhook forwarding, health checks). For custom logic (database writes, file processing), deploy a normal [Worker](/guide/worker/) instead; the agent still orchestrates jobs and crons, the worker executes them. ## Tool reference 73 tools covering the full queue surface. The agent discovers them automatically, so you rarely need this table; it is here so you know what is on the menu. | Category | Tools | Examples | |----------|-------|----------| | Jobs: add and query | 11 | `add_job`, `add_jobs_bulk`, `get_job`, `get_job_result`, `wait_for_job` | | Jobs: manage | 6 | `update_job_data`, `change_job_priority`, `move_to_delayed`, `discard_job` | | Jobs: consume | 8 | `pull_job`, `ack_job`, `fail_job`, `job_heartbeat`, `extend_lock` | | Queue control | 11 | `list_queues`, `pause_queue`, `drain_queue`, `clean_queue`, `get_job_counts` | | Dead letter queue | 4 | `get_dlq`, `retry_dlq`, `purge_dlq`, `retry_completed` | | Cron scheduling | 4 | `add_cron`, `list_crons`, `get_cron`, `delete_cron` | | Rate limits and concurrency | 4 | `set_rate_limit`, `clear_rate_limit`, `set_concurrency`, `clear_concurrency` | | Webhooks | 4 | `add_webhook`, `remove_webhook`, `list_webhooks`, `set_webhook_enabled` | | Workers | 3 | `register_worker`, `unregister_worker`, `worker_heartbeat` | | Monitoring | 11 | `get_stats`, `get_queue_stats`, `get_job_logs`, `get_prometheus_metrics` | | Workflows | 4 | `add_flow`, `add_flow_chain`, `add_flow_bulk_then`, `get_flow` | | HTTP handlers | 3 | `register_handler`, `unregister_handler`, `list_handlers` | Every tool name is prefixed `bunqueue_`. All tools return structured errors (`isError: true` with a plain message, never a stack trace). ### Prompts and resources The server also ships 3 pre-built diagnostic prompts, `bunqueue_health_report` (full health check with OK/WARNING/CRITICAL levels), `bunqueue_debug_queue` (deep dive on one queue) and `bunqueue_incident_response` (a triage playbook for "jobs not processing"), plus 5 read-only resources the agent can read at any time: `bunqueue://stats`, `bunqueue://queues`, `bunqueue://crons`, `bunqueue://workers`, `bunqueue://webhooks`. ## Environment variables | Variable | Default | Description | |----------|---------|-------------| | `BUNQUEUE_MODE` | `embedded` | `embedded` or `tcp` | | `BUNQUEUE_HOST` | `localhost` | TCP server host | | `BUNQUEUE_PORT` | `6789` | TCP server port | | `BUNQUEUE_TOKEN` | (none) | Auth token for TCP | | `DATA_PATH` | (none, in-memory) | SQLite path for embedded mode (`BUNQUEUE_DATA_PATH` and `BQ_DATA_PATH` take priority). Unset means jobs are not persisted | ## Troubleshooting **`bunx bunqueue-mcp` returns a 404.** `bunqueue-mcp` is not a standalone npm package, it is a binary inside `bunqueue`. Install `bunqueue` first, or use `bunx --package=bunqueue bunqueue-mcp` which always resolves correctly. **The server exits with `requires "@modelcontextprotocol/sdk"`.** Since v2.8.1 the MCP SDK is an optional peer dependency (so queue-only users get a 94% smaller install). Install it once where the server runs: `bun add @modelcontextprotocol/sdk`. **The agent does not see the tools.** Restart your MCP client, check the config JSON is valid, then run `bunx --package=bunqueue bunqueue-mcp` manually to see the actual error. **Jobs sit in `waiting` forever.** Nothing is processing the queue. Ask the agent to register an HTTP handler, or run a [Worker](/guide/worker/). --- # bunqueue Use Cases: Background Job Patterns for Bun Six production patterns with copy-paste code: email delivery, webhooks, image processing, payments, cron scheduling, and multi-step job flows. URL: https://bunqueue.dev/guide/use-cases/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · use-cases

The use cases teams run.

Six patterns you can copy into a real app: emails, webhooks, images, payments, cron and multi-step flows. Each one moves slow work out of your API request and into a background job.

This page shows the most common things people build with bunqueue, each with a small working example. If a term is new to you, it gets a plain-words explanation the first time it appears. ## The core pattern Every use case below is a variation of the same idea: your API handler adds a job (a unit of work saved as data) to a queue and returns immediately. A worker (a function that pulls jobs and runs them) does the slow part in the background. ```typescript import { Queue, Worker } from 'bunqueue/client'; // Embedded mode: the queue runs inside your process, no separate server. // dataPath persists jobs to a SQLite file so they survive restarts. const queue = new Queue('emails', { embedded: true, dataPath: './data/app.db' }); new Worker('emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: true, concurrency: 10 }); // up to 10 jobs in parallel // In your API handler: this returns in microseconds await queue.add('welcome', { to: 'user@example.com' }); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; // TCP mode: the broker owns persistence; configure its database on the server. const queue = new Queue('emails', { embedded: false }); new Worker('emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: false, concurrency: 10 }); // up to 10 jobs in parallel // In your API handler: this resolves after the broker accepts the job await queue.add('welcome', { to: 'user@example.com' }); ``` ```python from bunqueue import Queue, Worker # Connects to a bunqueue server on localhost:6789 queue = Queue("emails") def process(job): send_email(job.data) return {"sent": True} Worker("emails", process, concurrency=10) # up to 10 jobs in parallel # In your API handler: this returns as soon as the job is queued queue.add("welcome", {"to": "user@example.com"}) ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; // Connects to a bunqueue server on localhost:6789 $queue = new Queue('emails'); // In your API handler: this returns as soon as the job is queued $queue->add('welcome', ['to' => 'user@example.com']); // worker.php, a separate long-running process $worker = new Worker('emails', function (Bunqueue\Job $job) { sendEmail($job->data()); return ['sent' => true]; }); $worker->run(); ``` ```go // Connects to a bunqueue server on localhost:6789 queue := bunqueue.NewQueue("emails", bunqueue.Options{}) defer queue.Close() // In your API handler: this returns as soon as the job is queued queue.Add("welcome", map[string]any{"to": "user@example.com"}, nil) // worker, a separate long-running process worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return sendEmail(job.Data()) }, bunqueue.WorkerOptions{Concurrency: 10}) // up to 10 jobs in parallel worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, ProcessError, Queue, Value, Worker, WorkerOptions}; // Connects to a bunqueue server on localhost:6789 let queue = Queue::new("emails", ConnectionOptions::default()); // In your API handler: this returns as soon as the job is queued let data = Value::Map(vec![(Value::from("to"), Value::from("user@example.com"))]); queue.add("welcome", data, JobOptions::default())?; // worker, a separate long-running process let worker = Worker::new( "emails", |job| { deliver(job.data()) .map(|_| Value::from(true)) .map_err(|error| ProcessError::retryable(error.to_string())) }, WorkerOptions { concurrency: 10, ..Default::default() }, // up to 10 jobs in parallel ); worker.run()?; ``` ```elixir # Connects to a bunqueue server on localhost:6789 queue = Bunqueue.queue("emails") # In your API handler: this returns as soon as the job is queued {:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "user@example.com"}) # worker, a separate long-running process worker = Bunqueue.Worker.new("emails", fn job -> send_email(job.data) {:ok, %{sent: true}} end, concurrency: 10) Bunqueue.Worker.run(worker) ``` That is the whole model. The sections below add the options that make each use case reliable. New to bunqueue? Start with the [quickstart](/guide/quickstart/). ## Email delivery Sending email inside an API request is slow and fragile: the provider can be down or rate limited. Queue it instead, and let bunqueue retry on failure. ```typescript import { Queue, Worker } from 'bunqueue/client'; interface EmailJob { to: string; template: string; data: Record } const emails = new Queue('emails', { embedded: true, dataPath: './data/app.db', defaultJobOptions: { attempts: 5, // try up to 5 times backoff: 2000, // wait 2s, then 4s, 8s... between tries (exponential backoff) removeOnComplete: true, // drop finished jobs to keep the queue lean }, }); new Worker('emails', async (job) => { const result = await sendEmail(job.data); return { messageId: result.messageId }; }, { embedded: true, concurrency: 10 }); // One email await emails.add('welcome', { to: 'user@example.com', template: 'welcome', data: { name: 'John' } }); // Bulk newsletter, batched in one call await emails.addBulk( subscribers.map((s) => ({ name: 'newsletter', data: { to: s.email, template: 'news', data: {} } })) ); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; interface EmailJob { to: string; template: string; data: Record } const emails = new Queue('emails', { embedded: false, defaultJobOptions: { attempts: 5, // try up to 5 times backoff: 2000, // wait 2s, then 4s, 8s... between tries (exponential backoff) removeOnComplete: true, // drop finished jobs to keep the queue lean }, }); new Worker('emails', async (job) => { const result = await sendEmail(job.data); return { messageId: result.messageId }; }, { embedded: false, concurrency: 10 }); // One email await emails.add('welcome', { to: 'user@example.com', template: 'welcome', data: { name: 'John' } }); // Bulk newsletter, batched in one call await emails.addBulk( subscribers.map((s) => ({ name: 'newsletter', data: { to: s.email, template: 'news', data: {} } })) ); ``` ```python from bunqueue import Queue, Worker emails = Queue("emails") # Retry options are passed per add retry_opts = { "attempts": 5, # try up to 5 times "backoff": 2000, # wait 2s, then 4s, 8s... between tries (exponential backoff) "remove_on_complete": True, # drop finished jobs to keep the queue lean } def process(job): result = send_email(job.data) return {"message_id": result["message_id"]} Worker("emails", process, concurrency=10) # One email emails.add("welcome", {"to": "user@example.com", "template": "welcome", "data": {"name": "John"}}, **retry_opts) # Bulk newsletter, batched in one call emails.add_bulk([ {"name": "newsletter", "data": {"to": s["email"], "template": "news", "data": {}}, **retry_opts} for s in subscribers ]) ``` Not shown here. The PHP equivalent is in the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) and the [SDK guide](/guide/sdks/). ```go emails := bunqueue.NewQueue("emails", bunqueue.Options{}) defer emails.Close() // Retry options are passed per add retryOpts := bunqueue.JobOptions{ "attempts": 5, // try up to 5 times "backoff": 2000, // wait 2s, then 4s, 8s... between tries (exponential backoff) "removeOnComplete": true, // drop finished jobs to keep the queue lean } worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { result, err := sendEmail(job.Data()) if err != nil { return nil, err // retried automatically } return map[string]any{"messageId": result.MessageID}, nil }, bunqueue.WorkerOptions{Concurrency: 10}) go worker.Run() // One email emails.Add("welcome", map[string]any{"to": "user@example.com", "template": "welcome", "data": map[string]any{"name": "John"}}, retryOpts) // Bulk newsletter, batched in one call entries := make([]bunqueue.BulkEntry, 0, len(subscribers)) for _, s := range subscribers { entries = append(entries, bunqueue.BulkEntry{ Name: "newsletter", Data: map[string]any{"to": s.Email, "template": "news"}, Opts: retryOpts, }) } emails.AddBulk(entries) ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) and the [SDK guide](/guide/sdks/). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) and the [SDK guide](/guide/sdks/). *PHP, Rust and Elixir follow the same shape, `add` / `addBulk` on the producer and a worker with retries handled server-side, see the [SDK guide](/guide/sdks/).* If all 5 attempts fail, the job lands in the dead letter queue (DLQ), a holding area for jobs that ran out of retries. Inspect it with `queue.getDlq()` and retry with `queue.retryDlq()`. See the [DLQ guide](/guide/dlq/). ## Webhook delivery Partner endpoints go down and return 5xx errors. Treat every delivery as a job with retries, and let the DLQ auto-retry the stubborn ones on a schedule. ```typescript const webhooks = new Queue('webhooks', { embedded: true, dataPath: './data/app.db', defaultJobOptions: { attempts: 8, backoff: 5000 }, // 5s, 10s, 20s, 40s... }); // Jobs that exhaust all 8 attempts go to the DLQ. // Auto-retry the DLQ every hour, up to 3 times, then keep entries 7 days. webhooks.setDlqConfig({ autoRetry: true, autoRetryInterval: 3_600_000, maxAutoRetries: 3, maxAge: 604_800_000, }); new Worker('webhooks', async (job) => { const { endpoint, event, payload } = job.data; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Event': event }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000), // never hang on a dead endpoint }); if (!res.ok) throw new Error(`HTTP ${res.status}`); // throwing triggers a retry return { status: res.status }; }, { embedded: true, concurrency: 20 }); await webhooks.add('order.created', { endpoint: 'https://partner.com/webhooks', event: 'order.created', payload: { orderId: 'ORD-123' }, }); ``` ```typescript const webhooks = new Queue('webhooks', { embedded: false, defaultJobOptions: { attempts: 8, backoff: 5000 }, // 5s, 10s, 20s, 40s... }); // Jobs that exhaust all 8 attempts go to the DLQ. // Auto-retry the DLQ every hour, up to 3 times, then keep entries 7 days. await webhooks.setDlqConfigAsync({ autoRetry: true, autoRetryInterval: 3_600_000, maxAutoRetries: 3, maxAge: 604_800_000, }); new Worker('webhooks', async (job) => { const { endpoint, event, payload } = job.data; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Event': event }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000), // never hang on a dead endpoint }); if (!res.ok) throw new Error(`HTTP ${res.status}`); // throwing triggers a retry return { status: res.status }; }, { embedded: false, concurrency: 20 }); await webhooks.add('order.created', { endpoint: 'https://partner.com/webhooks', event: 'order.created', payload: { orderId: 'ORD-123' }, }); ``` ```python import requests from bunqueue import Queue, Worker webhooks = Queue("webhooks") # Jobs that exhaust all 8 attempts go to the DLQ. # Auto-retry the DLQ every hour, up to 3 times, then keep entries 7 days. webhooks.set_dlq_config({ "autoRetry": True, "autoRetryInterval": 3_600_000, "maxAutoRetries": 3, "maxAge": 604_800_000, }) def deliver(job): res = requests.post( job.data["endpoint"], json=job.data["payload"], headers={"X-Webhook-Event": job.data["event"]}, timeout=30, # never hang on a dead endpoint ) res.raise_for_status() # raising triggers a retry return {"status": res.status_code} Worker("webhooks", deliver, concurrency=20) webhooks.add("order.created", { "endpoint": "https://partner.com/webhooks", "event": "order.created", "payload": {"orderId": "ORD-123"}, }, attempts=8, backoff=5000) # 5s, 10s, 20s, 40s... ``` Not shown here. The PHP equivalent is in the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) and the [SDK guide](/guide/sdks/). ```go webhooks := bunqueue.NewQueue("webhooks", bunqueue.Options{}) defer webhooks.Close() // DLQ auto-retry configuration (setDlqConfig) is available from the // Bun, TypeScript and Python clients. worker := bunqueue.NewWorker("webhooks", func(job *bunqueue.Job) (any, error) { data := job.Data() payload, _ := json.Marshal(data["payload"]) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // never hang on a dead endpoint req, _ := http.NewRequestWithContext(ctx, "POST", data["endpoint"].(string), bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Webhook-Event", data["event"].(string)) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err // returning an error triggers a retry } defer res.Body.Close() if res.StatusCode >= 400 { return nil, fmt.Errorf("HTTP %d", res.StatusCode) } return map[string]any{"status": res.StatusCode}, nil }, bunqueue.WorkerOptions{Concurrency: 20}) go worker.Run() webhooks.Add("order.created", map[string]any{ "endpoint": "https://partner.com/webhooks", "event": "order.created", "payload": map[string]any{"orderId": "ORD-123"}, }, bunqueue.JobOptions{"attempts": 8, "backoff": 5000}) // 5s, 10s, 20s, 40s... ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) and the [SDK guide](/guide/sdks/). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) and the [SDK guide](/guide/sdks/). *PHP, Rust and Elixir deliver webhooks with the same worker shape (see the [SDK guide](/guide/sdks/)). DLQ auto-retry configuration (`setDlqConfig`) is available from the Bun, TypeScript and Python clients.* ## Image processing Generating thumbnails and variants during an upload request makes the upload slow. Queue one job per image, report progress as each variant finishes, and cap the runtime with a timeout. ```typescript const images = new Queue('images', { embedded: true, dataPath: './data/app.db', defaultJobOptions: { attempts: 3, timeout: 120_000 }, // kill stuck jobs after 2 minutes }); new Worker('images', async (job) => { const { sourceUrl, variants } = job.data; const source = await downloadImage(sourceUrl); const urls: Record = {}; for (let i = 0; i < variants.length; i++) { const v = variants[i]; // updateProgress(percent, message): your frontend can poll or subscribe to this await job.updateProgress(Math.round((i / variants.length) * 100), `Processing ${v.name}`); const out = await sharp(source).resize(v.width, v.height).webp().toBuffer(); urls[v.name] = await uploadToCDN(out, `${job.id}/${v.name}.webp`); } await job.updateProgress(100, 'Done'); return { urls }; }, { embedded: true, concurrency: 5 }); await images.add('product-image', { sourceUrl: 'https://uploads.example.com/raw/product-123.jpg', variants: [ { name: 'thumb', width: 150, height: 150 }, { name: 'full', width: 1200, height: 900 }, ], }); ``` ```typescript const images = new Queue('images', { embedded: false, defaultJobOptions: { attempts: 3, timeout: 120_000 }, // kill stuck jobs after 2 minutes }); new Worker('images', async (job) => { const { sourceUrl, variants } = job.data; const source = await downloadImage(sourceUrl); const urls: Record = {}; for (let i = 0; i < variants.length; i++) { const v = variants[i]; // updateProgress(percent, message): your frontend can poll or subscribe to this await job.updateProgress(Math.round((i / variants.length) * 100), `Processing ${v.name}`); const out = await sharp(source).resize(v.width, v.height).webp().toBuffer(); urls[v.name] = await uploadToCDN(out, `${job.id}/${v.name}.webp`); } await job.updateProgress(100, 'Done'); return { urls }; }, { embedded: false, concurrency: 5 }); await images.add('product-image', { sourceUrl: 'https://uploads.example.com/raw/product-123.jpg', variants: [ { name: 'thumb', width: 150, height: 150 }, { name: 'full', width: 1200, height: 900 }, ], }); ``` ```python from bunqueue import Queue, Worker images = Queue("images") def process(job): variants = job.data["variants"] source = download_image(job.data["sourceUrl"]) urls = {} for i, v in enumerate(variants): # update_progress(percent, message): your frontend can poll this job.update_progress(round(i / len(variants) * 100), f"Processing {v['name']}") out = resize_image(source, v["width"], v["height"]) # e.g. Pillow urls[v["name"]] = upload_to_cdn(out, f"{job.id}/{v['name']}.webp") job.update_progress(100, "Done") return {"urls": urls} Worker("images", process, concurrency=5) images.add("product-image", { "sourceUrl": "https://uploads.example.com/raw/product-123.jpg", "variants": [ {"name": "thumb", "width": 150, "height": 150}, {"name": "full", "width": 1200, "height": 900}, ], }, attempts=3, timeout=120_000) # kill stuck jobs after 2 minutes ``` Not shown here. The PHP equivalent is in the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) and the [SDK guide](/guide/sdks/). ```go images := bunqueue.NewQueue("images", bunqueue.Options{}) defer images.Close() worker := bunqueue.NewWorker("images", func(job *bunqueue.Job) (any, error) { data := job.Data() variants := data["variants"].([]any) source, err := downloadImage(data["sourceUrl"].(string)) if err != nil { return nil, err } urls := map[string]string{} for i, raw := range variants { v := raw.(map[string]any) name := v["name"].(string) // UpdateProgress(percent, message): your frontend can poll this job.UpdateProgress(float64(i)/float64(len(variants))*100, "Processing "+name) out := resizeVariant(source, v) // your image library of choice urls[name] = uploadToCDN(out, job.ID()+"/"+name+".webp") } job.UpdateProgress(100, "Done") return map[string]any{"urls": urls}, nil }, bunqueue.WorkerOptions{Concurrency: 5}) go worker.Run() images.Add("product-image", map[string]any{ "sourceUrl": "https://uploads.example.com/raw/product-123.jpg", "variants": []any{ map[string]any{"name": "thumb", "width": 150, "height": 150}, map[string]any{"name": "full", "width": 1200, "height": 900}, }, }, bunqueue.JobOptions{"attempts": 3, "timeout": 120000}) // kill stuck jobs after 2 minutes ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) and the [SDK guide](/guide/sdks/). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) and the [SDK guide](/guide/sdks/). *PHP, Rust and Elixir report progress the same way (`updateProgress` / `update_progress`), see the [Worker guide](/guide/worker/job-object/#use-the-job-object).* The same shape works for video transcoding or any long CPU-bound task, just raise the `timeout`. For heavy CPU work see [CPU-intensive workers](/guide/cpu-intensive-workers/). ## Payments and other critical jobs A payment job must never be lost, and its external side effect must be idempotent because bunqueue provides at-least-once delivery. Two job options support that design: - On SQLite, `durable: true` skips the 10ms write buffer and writes the job before `add()` returns. PostgreSQL admissions are already transactional and do not use that buffer. - `jobId` makes enqueueing idempotent: adding the same unfinished `jobId` twice returns the existing job instead of creating another generation. Use the same key with the payment provider because a lease recovery can redeliver a job after its side effect completed but before its ACK committed. ```typescript const payments = new Queue('payments', { embedded: true, dataPath: './data/payments.db', // durable writes need a SQLite file defaultJobOptions: { attempts: 3, backoff: 5000, timeout: 60_000 }, }); // Failed payments need a human, never an automatic re-charge payments.setDlqConfig({ autoRetry: false, maxAge: 2_592_000_000 }); // keep 30 days new Worker('payments', async (job) => { const { orderId, amount, idempotencyKey } = job.data; const intent = await stripe.paymentIntents.create( { amount, currency: 'usd', confirm: true }, { idempotencyKey } // provider-side guard against double charges ); if (intent.status !== 'succeeded') throw new Error(`Payment failed: ${intent.status}`); await recordTransaction(orderId, intent.id); return { paymentIntentId: intent.id }; }, { embedded: true, concurrency: 5 }); await payments.add( 'charge', { orderId: 'ORD-123', amount: 9999, idempotencyKey: 'order-ORD-123' }, // Custom ID deduplicates while retained; durable closes SQLite's admission buffer window. { jobId: 'charge-ORD-123', durable: true } ); ``` ```typescript const payments = new Queue('payments', { embedded: false, defaultJobOptions: { attempts: 3, backoff: 5000, timeout: 60_000 }, }); // Failed payments need a human, never an automatic re-charge await payments.setDlqConfigAsync({ autoRetry: false, maxAge: 2_592_000_000 }); // keep 30 days new Worker('payments', async (job) => { const { orderId, amount, idempotencyKey } = job.data; const intent = await stripe.paymentIntents.create( { amount, currency: 'usd', confirm: true }, { idempotencyKey } // provider-side guard against double charges ); if (intent.status !== 'succeeded') throw new Error(`Payment failed: ${intent.status}`); await recordTransaction(orderId, intent.id); return { paymentIntentId: intent.id }; }, { embedded: false, concurrency: 5 }); await payments.add( 'charge', { orderId: 'ORD-123', amount: 9999, idempotencyKey: 'order-ORD-123' }, // Custom ID deduplicates while retained; durable closes SQLite's admission buffer window. { jobId: 'charge-ORD-123', durable: true } ); ``` ```python from bunqueue import Queue, Worker payments = Queue("payments") # Failed payments need a human, never an automatic re-charge payments.set_dlq_config({"autoRetry": False, "maxAge": 2_592_000_000}) # keep 30 days def charge(job): intent = stripe.PaymentIntent.create( amount=job.data["amount"], currency="usd", confirm=True, idempotency_key=job.data["idempotencyKey"], # guard against double charges ) if intent.status != "succeeded": raise Exception(f"Payment failed: {intent.status}") record_transaction(job.data["orderId"], intent.id) return {"payment_intent_id": intent.id} Worker("payments", charge, concurrency=5) payments.add( "charge", {"orderId": "ORD-123", "amount": 9999, "idempotencyKey": "order-ORD-123"}, job_id="charge-ORD-123", durable=True, # SQLite closes its buffer window; PG is transactional attempts=3, backoff=5000, timeout=60_000, ) ``` Not shown here. The PHP equivalent is in the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) and the [SDK guide](/guide/sdks/). ```go payments := bunqueue.NewQueue("payments", bunqueue.Options{}) defer payments.Close() worker := bunqueue.NewWorker("payments", func(job *bunqueue.Job) (any, error) { data := job.Data() // Pass data["idempotencyKey"] to your provider as its idempotency key: // a provider-side guard against double charges. intent, err := chargeProvider(data) if err != nil { return nil, err } if err := recordTransaction(data["orderId"].(string), intent.ID); err != nil { return nil, err } return map[string]any{"paymentIntentId": intent.ID}, nil }, bunqueue.WorkerOptions{Concurrency: 5}) go worker.Run() payments.Add("charge", map[string]any{"orderId": "ORD-123", "amount": 9999, "idempotencyKey": "order-ORD-123"}, bunqueue.JobOptions{ "jobId": "charge-ORD-123", // no duplicate "durable": true, // closes SQLite's buffered-admission window; PG is already transactional "attempts": 3, "backoff": 5000, "timeout": 60000, }) ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) and the [SDK guide](/guide/sdks/). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) and the [SDK guide](/guide/sdks/). *PHP, Rust and Elixir support `jobId` and `durable` identically, see the [SDK guide](/guide/sdks/#job-options). DLQ auto-retry configuration (`setDlqConfig`) is available from the Bun, TypeScript and Python clients.* ## Scheduled tasks (cron) Recurring jobs use `upsertJobScheduler()`. Schedules survive restarts when the selected backend is persistent: SQLite stores them in embedded or single-broker server mode, while PostgreSQL stores and coordinates them across the broker fleet. A cron expression like `0 3 * * *` means "every day at 3 AM". ```typescript const scheduled = new Queue('scheduled', { embedded: true, dataPath: './data/app.db' }); // Daily cleanup at 3 AM await scheduled.upsertJobScheduler( 'daily-cleanup', { pattern: '0 3 * * *' }, { data: { task: 'cleanup' } } ); // Health check every 5 minutes await scheduled.upsertJobScheduler( 'health-check', { every: 300_000 }, { data: { task: 'health-check' } } ); new Worker('scheduled', async (job) => { if (job.data.task === 'cleanup') return { deleted: await cleanupOldRecords() }; return await checkSystemHealth(); }, { embedded: true }); ``` ```typescript const scheduled = new Queue('scheduled', { embedded: false }); // Daily cleanup at 3 AM await scheduled.upsertJobScheduler( 'daily-cleanup', { pattern: '0 3 * * *' }, { data: { task: 'cleanup' } } ); // Health check every 5 minutes await scheduled.upsertJobScheduler( 'health-check', { every: 300_000 }, { data: { task: 'health-check' } } ); new Worker('scheduled', async (job) => { if (job.data.task === 'cleanup') return { deleted: await cleanupOldRecords() }; return await checkSystemHealth(); }, { embedded: false }); ``` ```python from bunqueue import Queue, Worker scheduled = Queue("scheduled") # Daily cleanup at 3 AM scheduled.upsert_job_scheduler("daily-cleanup", {"pattern": "0 3 * * *"}, {"data": {"task": "cleanup"}}) # Health check every 5 minutes scheduled.upsert_job_scheduler("health-check", {"every": 300_000}, {"data": {"task": "health-check"}}) def process(job): if job.data["task"] == "cleanup": return {"deleted": cleanup_old_records()} return check_system_health() Worker("scheduled", process) ``` ```php $scheduled = new Bunqueue\Queue('scheduled'); // Daily cleanup at 3 AM $scheduled->upsertJobScheduler('daily-cleanup', ['pattern' => '0 3 * * *'], ['data' => ['task' => 'cleanup']], ); // Health check every 5 minutes $scheduled->upsertJobScheduler('health-check', ['every' => 300000], ['data' => ['task' => 'health-check']], ); $worker = new Bunqueue\Worker('scheduled', function (Bunqueue\Job $job) { if ($job->data()['task'] === 'cleanup') { return ['deleted' => cleanupOldRecords()]; } return checkSystemHealth(); }); $worker->run(); ``` ```go scheduled := bunqueue.NewQueue("scheduled", bunqueue.Options{}) defer scheduled.Close() // Daily cleanup at 3 AM scheduled.UpsertJobScheduler("daily-cleanup", bunqueue.SchedulerRepeat{Pattern: "0 3 * * *"}, bunqueue.SchedulerTemplate{Data: map[string]any{"task": "cleanup"}}, ) // Health check every 5 minutes scheduled.UpsertJobScheduler("health-check", bunqueue.SchedulerRepeat{EveryMs: 300000}, bunqueue.SchedulerTemplate{Data: map[string]any{"task": "health-check"}}, ) worker := bunqueue.NewWorker("scheduled", func(job *bunqueue.Job) (any, error) { if job.Data()["task"] == "cleanup" { return map[string]any{"deleted": cleanupOldRecords()}, nil } return checkSystemHealth() }, bunqueue.WorkerOptions{}) worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, Queue, SchedulerRepeat, SchedulerTemplate, Value}; let scheduled = Queue::new("scheduled", ConnectionOptions::default()); // Daily cleanup at 3 AM scheduled.upsert_job_scheduler( "daily-cleanup", SchedulerRepeat { pattern: Some("0 3 * * *".into()), ..Default::default() }, SchedulerTemplate { data: Value::Map(vec![(Value::from("task"), Value::from("cleanup"))]), ..Default::default() }, )?; // Health check every 5 minutes scheduled.upsert_job_scheduler( "health-check", SchedulerRepeat { every_ms: Some(300_000), ..Default::default() }, SchedulerTemplate { data: Value::Map(vec![(Value::from("task"), Value::from("health-check"))]), ..Default::default() }, )?; ``` ```elixir scheduled = Bunqueue.queue("scheduled") # Daily cleanup at 3 AM :ok = Bunqueue.Queue.upsert_scheduler(scheduled, "daily-cleanup", %{pattern: "0 3 * * *"}, %{data: %{task: "cleanup"}} ) # Health check every 5 minutes :ok = Bunqueue.Queue.upsert_scheduler(scheduled, "health-check", %{every: 300_000}, %{data: %{task: "health-check"}} ) worker = Bunqueue.Worker.new("scheduled", fn job -> case job.data["task"] do "cleanup" -> {:ok, %{deleted: cleanup_old_records()}} _ -> {:ok, check_system_health()} end end) Bunqueue.Worker.run(worker) ``` Timezones, one-off delayed jobs and the `repeat` shorthand on `queue.add()` are covered in the [cron guide](/guide/cron/). ## Multi-step flows Some work has dependencies: an order ships only after inventory and payment both check out. `FlowProducer` runs child jobs first, in parallel, then runs the parent with access to every child result. ```typescript import { FlowProducer, Worker } from 'bunqueue/client'; type OrderData = { orderId: string }; const flow = new FlowProducer({ embedded: true }); const checks = new Worker('checks', async (job) => ({ check: job.name, approved: true, }), { embedded: true }); const orders = new Worker('orders', async (job) => { // Children finished first; read what each one returned const results = await job.getChildrenValues(); return { orderId: job.data.orderId, shipped: true, checks: results }; }, { embedded: true }); const node = await flow.add({ name: 'fulfill-order', queueName: 'orders', data: { orderId: 'ORD-123' }, children: [ { name: 'check-inventory', queueName: 'checks', data: { orderId: 'ORD-123' } }, { name: 'check-payment', queueName: 'checks', data: { orderId: 'ORD-123' } }, ], }); const result = await node.job.waitUntilFinished(null, 10_000); console.log(result); await checks.close(); await orders.close(); await flow.close(); ``` ```typescript import { FlowProducer, Worker } from 'bunqueue-client'; type OrderData = { orderId: string }; const flow = new FlowProducer({ embedded: false }); const checks = new Worker('checks', async (job) => ({ check: job.name, approved: true, }), { embedded: false }); const orders = new Worker('orders', async (job) => { // Children finished first; read what each one returned const results = await job.getChildrenValues(); return { orderId: job.data.orderId, shipped: true, checks: results }; }, { embedded: false }); const node = await flow.add({ name: 'fulfill-order', queueName: 'orders', data: { orderId: 'ORD-123' }, children: [ { name: 'check-inventory', queueName: 'checks', data: { orderId: 'ORD-123' } }, { name: 'check-payment', queueName: 'checks', data: { orderId: 'ORD-123' } }, ], }); const result = await node.job.waitUntilFinished(null, 10_000); console.log(result); await checks.close(); await orders.close(); await flow.close(); ``` ```python from bunqueue import FlowProducer, Worker flow = FlowProducer() flow.add({ "name": "fulfill-order", "queueName": "orders", "data": {"orderId": "ORD-123"}, "children": [ {"name": "check-inventory", "queueName": "checks", "data": {"orderId": "ORD-123"}}, {"name": "check-payment", "queueName": "checks", "data": {"orderId": "ORD-123"}}, ], }) def fulfill(job): # Children finished first; read what each one returned results = job.get_children_values() return ship_order(job.data["orderId"], results) Worker("orders", fulfill) ``` ```php use Bunqueue\FlowProducer; use Bunqueue\Queue; use Bunqueue\Worker; $flow = new FlowProducer(); $flow->add([ 'name' => 'fulfill-order', 'queueName' => 'orders', 'data' => ['orderId' => 'ORD-123'], 'children' => [ ['name' => 'check-inventory', 'queueName' => 'checks', 'data' => ['orderId' => 'ORD-123']], ['name' => 'check-payment', 'queueName' => 'checks', 'data' => ['orderId' => 'ORD-123']], ], ]); $orders = new Queue('orders'); $worker = new Worker('orders', function (Bunqueue\Job $job) use ($orders) { // Children finished first; read what each one returned $results = $orders->getChildrenValues($job->id()); return shipOrder($job->data()['orderId'], $results); }); $worker->run(); ``` ```go flow := bunqueue.NewFlowProducer(bunqueue.Options{}) defer flow.Close() flow.Add(bunqueue.FlowJob{ Name: "fulfill-order", QueueName: "orders", Data: map[string]any{"orderId": "ORD-123"}, Children: []bunqueue.FlowJob{ {Name: "check-inventory", QueueName: "checks", Data: map[string]any{"orderId": "ORD-123"}}, {Name: "check-payment", QueueName: "checks", Data: map[string]any{"orderId": "ORD-123"}}, }, }) orders := bunqueue.NewQueue("orders", bunqueue.Options{}) worker := bunqueue.NewWorker("orders", func(job *bunqueue.Job) (any, error) { // Children finished first; read what each one returned results, err := orders.GetChildrenValues(job.ID()) if err != nil { return nil, err } return shipOrder(job.Data()["orderId"].(string), results) }, bunqueue.WorkerOptions{}) worker.Run() ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) and the [SDK guide](/guide/sdks/). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) and the [SDK guide](/guide/sdks/). *Rust and Elixir support the same flow trees (`FlowProducer::add` / `Bunqueue.FlowProducer.add`) and sequential chains, see the [SDK guide](/guide/sdks/#flows).* `flow.addChain([...])` runs jobs one after another, and `flow.addBulkThen(jobs, finalJob)` fans out in parallel and merges at the end (fan-in is available in the TypeScript and Python clients). See the [flow guide](/guide/flow/). For richer orchestration (branching, rollback on failure, human approval steps) use the [workflow engine](/guide/workflow/). ## Job options cheat sheet The options you saw above, all set per job or via `defaultJobOptions`: | Option | What it does | Default | | --- | --- | --- | | `attempts` | Max tries before the job goes to the DLQ | 3 | | `backoff` | Base delay between retries, doubles each time | 1000 ms | | `timeout` | Max processing time before the job is failed | none | | `priority` | Higher numbers run sooner | 0 | | `delay` | Wait this many ms before the job is runnable | 0 | | `jobId` | Custom ID, adding the same ID twice returns the existing job | auto | | `durable` | SQLite: bypass its 10ms buffer; PostgreSQL is already transactional | false | | `removeOnComplete` | Delete the job once it succeeds | false | Full list in the [queue guide](/guide/queue/). ## Gotchas - **No `dataPath` means no persistence.** An embedded queue without `dataPath` (or a `DATA_PATH` env var) keeps everything in memory and loses it on restart. - **SQLite's default write buffer trades 10ms for speed.** A hard crash can lose jobs accepted in that window; use `durable: true` where that matters. PostgreSQL has no equivalent admission buffer. - **Throwing is how you retry.** A worker that catches every error and returns normally marks the job completed. Let errors propagate when you want a retry. - **Do not auto-retry money.** Set `setDlqConfig({ autoRetry: false })` on payment-like queues so failed charges wait for review. - **Close workers on shutdown.** `await worker.close()` waits for active jobs to finish; `worker.close(true)` forces a stop. See [production](/guide/production/) for the full shutdown pattern. ## More patterns - [AI agents via MCP](/guide/mcp/), let Claude or any MCP client schedule and monitor jobs - [Multi-tenant isolation](/guide/queue-group/), one namespaced queue set per tenant - [Rate-limited API calls](/guide/rate-limiting/), token buckets and worker limiters - [Edge and IoT forwarding](/guide/iot-edge/), queue locally, drain to a central server - [Copy-paste examples](/examples/), shorter recipes for common tasks --- # Simple Mode: Queue + Worker in One Object bunqueue Simple Mode combines Queue and Worker in one object: named routes, onion middleware, cron, events, and 12 built-in features with zero boilerplate. URL: https://bunqueue.dev/guide/simple-mode/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · simple mode

Queue and worker, one object.

Simple Mode gives you a Queue and a Worker in a single object. Add jobs, process them, add middleware, schedule crons, all from one place, one thing to close on shutdown.

If your producer and consumer live in the same process, creating a `Queue` and a `Worker` separately is boilerplate. `Bunqueue` wraps both: *Simple Mode ships in the Bun package, the TypeScript client (`bunqueue-client`) and the Python SDK; in PHP, Go, Rust and Elixir compose Queue + Worker directly.* ```typescript import { Bunqueue } from 'bunqueue/client'; const app = new Bunqueue<{ to: string }>('emails', { embedded: true, processor: async (job) => { console.log(`Sending to ${job.data.to}`); return { sent: true }; }, }); await app.add('send', { to: 'alice@example.com' }); ``` ```typescript import { Bunqueue } from 'bunqueue-client'; const app = new Bunqueue<{ to: string }>('emails', { embedded: false, processor: async (job) => { console.log(`Sending to ${job.data.to}`); return { sent: true }; }, }); await app.add('send', { to: 'alice@example.com' }); ``` ```python from bunqueue import Bunqueue def send(job): print(f"Sending to {job.data['to']}") return {"sent": True} # connects to localhost:6789 (embedded mode is Bun-only) app = Bunqueue("emails", processor=send) app.add("send", {"to": "alice@example.com"}) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). :::tip[When to use] Use `Bunqueue` when producer and consumer are in the **same process**. For distributed systems (separate producer and worker services), use [`Queue`](/guide/queue/) + [`Worker`](/guide/worker/) directly. ::: Under the hood, `Bunqueue` is exactly `new Queue()` + `new Worker()` plus optional subsystems. Each job flows through: circuit breaker check → TTL check → cancellation setup → retry wrapper → middleware → your processor. Every subsystem is off until you configure it. ## Routes Route jobs to different handlers by name: ```typescript const app = new Bunqueue<{ to: string }>('notifications', { embedded: true, routes: { 'send-email': async (job) => { await sendEmail(job.data.to); return { channel: 'email' }; }, 'send-sms': async (job) => { await sendSMS(job.data.to); return { channel: 'sms' }; }, }, }); await app.add('send-email', { to: 'alice' }); await app.add('send-sms', { to: 'bob' }); ``` ```typescript const app = new Bunqueue<{ to: string }>('notifications', { embedded: false, routes: { 'send-email': async (job) => { await sendEmail(job.data.to); return { channel: 'email' }; }, 'send-sms': async (job) => { await sendSMS(job.data.to); return { channel: 'sms' }; }, }, }); await app.add('send-email', { to: 'alice' }); await app.add('send-sms', { to: 'bob' }); ``` ```python def email(job): send_email(job.data["to"]) return {"channel": "email"} def sms(job): send_sms(job.data["to"]) return {"channel": "sms"} app = Bunqueue( "notifications", routes={"send-email": email, "send-sms": sms}, ) app.add("send-email", {"to": "alice"}) app.add("send-sms", {"to": "bob"}) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). :::caution Use **one** of `processor`, `routes`, or `batch`. Passing multiple or none throws an error. ::: ## Middleware Wraps every job execution, like middleware in a web framework. Each middleware receives the job and a `next()` function: ```typescript // Timing middleware app.use(async (job, next) => { const start = Date.now(); const result = await next(); console.log(`${job.name}: ${Date.now() - start}ms`); return result; }); // Error recovery middleware app.use(async (job, next) => { try { return await next(); } catch (err) { return { recovered: true, error: err.message }; } }); ``` ```typescript // Timing middleware app.use(async (job, next) => { const start = Date.now(); const result = await next(); console.log(`${job.name}: ${Date.now() - start}ms`); return result; }); // Error recovery middleware app.use(async (job, next) => { try { return await next(); } catch (err) { return { recovered: true, error: err.message }; } }); ``` ```python import time # Timing middleware def timing(job, next_fn): start = time.monotonic() result = next_fn() print(f"{job.name}: {int((time.monotonic() - start) * 1000)}ms") return result app.use(timing) # Error recovery middleware def recover(job, next_fn): try: return next_fn() except Exception as err: return {"recovered": True, "error": str(err)} app.use(recover) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). Execution order is onion-style: `mw1 → mw2 → processor → mw2 → mw1`. With no middleware added, there is zero overhead. ## Batch processing Accumulate N jobs and process them together, ideal for bulk database inserts: ```typescript const app = new Bunqueue('db-inserts', { embedded: true, batch: { size: 50, // flush every 50 jobs timeout: 2000, // or every 2 seconds, whichever comes first processor: async (jobs) => { const rows = jobs.map(j => j.data.row); await db.insertMany('table', rows); return jobs.map(() => ({ inserted: true })); }, }, }); ``` ```typescript const app = new Bunqueue('db-inserts', { embedded: false, batch: { size: 50, // flush every 50 jobs timeout: 2000, // or every 2 seconds, whichever comes first processor: async (jobs) => { const rows = jobs.map(j => j.data.row); await db.insertMany('table', rows); return jobs.map(() => ({ inserted: true })); }, }, }); ``` ```python def insert_rows(jobs): rows = [job.data["row"] for job in jobs] db.insert_many("table", rows) return [{"inserted": True} for _ in jobs] app = Bunqueue( "db-inserts", concurrency=50, # batched jobs hold their slot until the batch flushes batch={ "size": 50, # flush every 50 jobs "timeout": 2000, # or every 2 seconds, whichever comes first "processor": insert_rows, }, ) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). On `close()`, remaining buffered jobs are flushed. ## Advanced retry Five backoff strategies (how long to wait between retry attempts) plus a predicate to decide what is worth retrying: ```typescript const app = new Bunqueue('api-calls', { embedded: true, processor: async (job) => { const res = await fetch(job.data.url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { status: res.status }; }, retry: { maxAttempts: 5, delay: 1000, strategy: 'jitter', // 'fixed' | 'exponential' | 'jitter' | 'fibonacci' | 'custom' retryIf: (error) => error.message.includes('503'), // only retry on 503 }, }); ``` ```typescript const app = new Bunqueue('api-calls', { embedded: false, processor: async (job) => { const res = await fetch(job.data.url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { status: res.status }; }, retry: { maxAttempts: 5, delay: 1000, strategy: 'jitter', // 'fixed' | 'exponential' | 'jitter' | 'fibonacci' | 'custom' retryIf: (error) => error.message.includes('503'), // only retry on 503 }, }); ``` ```python def call_api(job): response = http_get(job.data["url"]) # your HTTP client if response.status >= 400: raise RuntimeError(f"HTTP {response.status}") return {"status": response.status} app = Bunqueue( "api-calls", processor=call_api, retry={ "max_attempts": 5, "delay": 1000, "strategy": "jitter", # "fixed" | "exponential" | "jitter" | "fibonacci" | "custom" "retry_if": lambda error, attempt: "503" in str(error), # only retry on 503 }, ) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). | Strategy | Formula | Use case | |----------|---------|----------| | `fixed` | `delay` every time | Rate-limited APIs | | `exponential` | `delay × 2^(attempt-1)` | General purpose | | `jitter` | `delay × 2^(attempt-1) × random(0.5-1.5)` | Avoid retry storms | | `fibonacci` | `delay × fib(attempt)` (1x, 2x, 3x, 5x, 8x, ...) | Gradual backoff | | `custom` | `customBackoff(attempt, error) → ms` | Anything | This is **in-process retry**: the job stays active while retrying. Different from core `attempts`/`backoff`, which re-queues the job. Synchronous throws and rejected Promises follow the same retry policy. The pending backoff is tied to the job's cancellation signal, so `cancel()` or `close()` clears it and cannot invoke the processor again after shutdown. ## Graceful cancellation Cancel running jobs via an AbortController signal (the standard way to tell async code to stop): ```typescript const app = new Bunqueue('encoding', { embedded: true, processor: async (job) => { const signal = app.getSignal(job.id); for (const chunk of chunks) { if (signal?.aborted) throw new Error('Cancelled'); await encode(chunk); } return { done: true }; }, }); const job = await app.add('video', { file: 'big.mp4' }); app.cancel(job.id); // cancel immediately app.cancel(job.id, 5000); // cancel after 5s grace period ``` ```typescript const app = new Bunqueue('encoding', { embedded: false, processor: async (job) => { const signal = app.getSignal(job.id); for (const chunk of chunks) { if (signal?.aborted) throw new Error('Cancelled'); await encode(chunk); } return { done: true }; }, }); const job = await app.add('video', { file: 'big.mp4' }); app.cancel(job.id); // cancel immediately app.cancel(job.id, 5000); // cancel after 5s grace period ``` ```python def encode_video(job): signal = app.get_signal(job.id) for chunk in chunks: if signal is not None and signal.aborted: raise RuntimeError("Cancelled") encode(chunk) return {"done": True} app = Bunqueue("encoding", processor=encode_video) job = app.add("video", {"file": "big.mp4"}) app.cancel(job.id) # cancel immediately app.cancel(job.id, 5000) # cancel after 5s grace period ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). In TypeScript the signal is a standard `AbortSignal`, so it works with `fetch` too: `await fetch(url, { signal })`. Python's `CancelSignal` exposes the same cooperative `aborted` flag. Cancellation applies once the job is running and its controller has been registered. If code adds and immediately cancels a job, wait for the Worker's `active` event first; sleeping for a fixed interval races worker polling. Calling `cancel()` for an unknown, queued, or finished id is a no-op. Repeated graceful calls keep the earliest requested deadline: a shorter grace period advances cancellation, while a longer one cannot postpone it. An immediate call supersedes and clears the pending grace timer. Job completion and `close()` also clear owned cancellation timers, so they do not keep the process alive after the job or app has finished. This cleanup also runs when a processor or middleware throws synchronously before returning a Promise, and it remains guaranteed if a user circuit-breaker callback throws. ## Circuit breaker When a downstream service is down, retrying every job just burns attempts. A circuit breaker pauses the worker after too many consecutive failures, then probes periodically until the service recovers: ```typescript const app = new Bunqueue('payments', { embedded: true, processor: async (job) => paymentGateway.charge(job.data), circuitBreaker: { threshold: 5, // open (pause) after 5 consecutive failures resetTimeout: 30000, // try again after 30s onOpen: () => alert('Gateway down!'), onClose: () => alert('Gateway recovered'), }, }); app.getCircuitState(); // 'closed' | 'open' | 'half-open' app.resetCircuit(); // force close + resume worker ``` ```typescript const app = new Bunqueue('payments', { embedded: false, processor: async (job) => paymentGateway.charge(job.data), circuitBreaker: { threshold: 5, // open (pause) after 5 consecutive failures resetTimeout: 30000, // try again after 30s onOpen: () => alert('Gateway down!'), onClose: () => alert('Gateway recovered'), }, }); app.getCircuitState(); // 'closed' | 'open' | 'half-open' app.resetCircuit(); // force close + resume worker ``` ```python app = Bunqueue( "payments", processor=lambda job: payment_gateway.charge(job.data), circuit_breaker={ "threshold": 5, # open (pause) after 5 consecutive failures "reset_timeout": 30000, # try again after 30s "on_open": lambda failures: alert("Gateway down!"), "on_close": lambda: alert("Gateway recovered"), }, ) app.get_circuit_state() # "closed" | "open" | "half-open" app.reset_circuit() # force close + resume worker ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). When both retry and circuit breaker are active: one job exhausting all its retries counts as one circuit breaker failure. `close()` terminally destroys the breaker, so aborting a pending retry during shutdown cannot call hooks or arm another reset timer. Explicit cancellation keeps the normal cooperative outcome: a processor that ignores the signal and completes still reports success, while a cancellation-induced rejection reports failure. ## Event triggers Create follow-up jobs automatically when a job completes or fails: ```typescript const app = new Bunqueue('orders', { embedded: true, routes: { 'place-order': async (job) => ({ orderId: job.data.id, total: 99 }), 'send-receipt': async (job) => ({ sent: true }), 'fraud-alert': async (job) => ({ alerted: true }), }, }); // On complete → create follow-up app.trigger({ on: 'place-order', create: 'send-receipt', data: (result, job) => ({ id: job.data.id }), }); // Conditional trigger; `result` is typed as unknown, cast it app.trigger({ on: 'place-order', create: 'fraud-alert', data: (result) => ({ amount: (result as { total: number }).total }), condition: (result) => (result as { total: number }).total > 1000, }); ``` ```typescript const app = new Bunqueue('orders', { embedded: false, routes: { 'place-order': async (job) => ({ orderId: job.data.id, total: 99 }), 'send-receipt': async (job) => ({ sent: true }), 'fraud-alert': async (job) => ({ alerted: true }), }, }); // On complete → create follow-up app.trigger({ on: 'place-order', create: 'send-receipt', data: (result, job) => ({ id: job.data.id }), }); // Conditional trigger; `result` is typed as unknown, cast it app.trigger({ on: 'place-order', create: 'fraud-alert', data: (result) => ({ amount: (result as { total: number }).total }), condition: (result) => (result as { total: number }).total > 1000, }); ``` ```python app = Bunqueue( "orders", routes={ "place-order": lambda job: {"order_id": job.data["id"], "total": 99}, "send-receipt": lambda job: {"sent": True}, "fraud-alert": lambda job: {"alerted": True}, }, ) # On complete -> create follow-up app.trigger({ "on": "place-order", "create": "send-receipt", "data": lambda result, job: {"id": job.data["id"]}, }) # Conditional trigger; both callbacks receive (result, job) app.trigger({ "on": "place-order", "create": "fraud-alert", "data": lambda result, job: {"amount": result["total"]}, "condition": lambda result, job: result["total"] > 1000, }) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). Triggers chain: `step-1 → step-2 → step-3`. For anything more complex, use the [Workflow Engine](/guide/workflow/). ## Job TTL Expire jobs that waited too long, checked when the worker picks the job up: ```typescript const app = new Bunqueue('otp', { embedded: true, processor: async (job) => verifyOTP(job.data.code), ttl: { defaultTtl: 300000, // 5 minutes for all jobs perName: { 'verify-otp': 60000, // 1 minute for OTP 'daily-report': 0, // never expires }, }, }); // Update at runtime app.setDefaultTtl(120000); app.setNameTtl('flash-sale', 30000); ``` ```typescript const app = new Bunqueue('otp', { embedded: false, processor: async (job) => verifyOTP(job.data.code), ttl: { defaultTtl: 300000, // 5 minutes for all jobs perName: { 'verify-otp': 60000, // 1 minute for OTP 'daily-report': 0, // never expires }, }, }); // Update at runtime app.setDefaultTtl(120000); app.setNameTtl('flash-sale', 30000); ``` ```python app = Bunqueue( "otp", processor=lambda job: verify_otp(job.data["code"]), ttl={ "default_ttl": 300000, # 5 minutes for all jobs "per_name": { "verify-otp": 60000, # 1 minute for OTP "daily-report": 0, # never expires }, }, ) # Update at runtime app.set_default_ttl(120000) app.set_name_ttl("flash-sale", 30000) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). Resolution order: `perName[job.name]` → `defaultTtl` → `0` (no TTL). ## Priority aging Low-priority jobs can starve behind a stream of high-priority ones. Priority aging automatically boosts jobs the longer they wait: ```typescript const app = new Bunqueue('tasks', { embedded: true, processor: async (job) => ({ done: true }), priorityAging: { interval: 60000, // check every 60s minAge: 300000, // start boosting after 5 minutes boost: 2, // +2 priority per tick maxPriority: 100, // cap maxScan: 200, // max jobs per tick }, }); ``` ```typescript const app = new Bunqueue('tasks', { embedded: false, processor: async (job) => ({ done: true }), priorityAging: { interval: 60000, // check every 60s minAge: 300000, // start boosting after 5 minutes boost: 2, // +2 priority per tick maxPriority: 100, // cap maxScan: 200, // max jobs per tick }, }); ``` ```python app = Bunqueue( "tasks", processor=lambda job: {"done": True}, priority_aging={ "interval": 60000, # check every 60s "min_age": 300000, # start boosting after 5 minutes "boost": 2, # +2 priority per tick "max_priority": 100, # cap "max_scan": 200, # max jobs per tick }, ) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). The aging scheduler owns one interval. Shutdown invalidates callbacks that were already queued or waiting on a job query before clearing that interval, so an old tick cannot modify priorities after `close()`. ## Deduplication defaults Prevent duplicate jobs automatically: jobs with the same name + data get the same dedup ID within the TTL window: ```typescript const app = new Bunqueue('webhooks', { embedded: true, processor: async (job) => processWebhook(job.data), deduplication: { ttl: 60000, // dedup window: 60 seconds }, }); await app.add('hook', { event: 'user.created', userId: '123' }); await app.add('hook', { event: 'user.created', userId: '123' }); // deduplicated! await app.add('hook', { event: 'user.updated', userId: '123' }); // different data → new job ``` ```typescript const app = new Bunqueue('webhooks', { embedded: false, processor: async (job) => processWebhook(job.data), deduplication: { ttl: 60000, // dedup window: 60 seconds }, }); await app.add('hook', { event: 'user.created', userId: '123' }); await app.add('hook', { event: 'user.created', userId: '123' }); // deduplicated! await app.add('hook', { event: 'user.updated', userId: '123' }); // different data → new job ``` ```python app = Bunqueue( "webhooks", processor=lambda job: process_webhook(job.data), deduplication={ "ttl": 60000, # dedup window: 60 seconds }, ) app.add("hook", {"event": "user.created", "userId": "123"}) app.add("hook", {"event": "user.created", "userId": "123"}) # deduplicated! app.add("hook", {"event": "user.updated", "userId": "123"}) # different data -> new job ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). Override per job: `await app.add('task', data, { deduplication: { id: 'my-id', ttl: 5000 } })` (Python: `app.add("task", data, deduplication={"id": "my-id", "ttl": 5000})`). Strategies (`extend`, `replace`) are explained in [Queue → Deduplication](/guide/queue/deduplication/). ### Debounce The `debounce: { ttl }` option attaches a default debounce id (the job name) to every job. It is BullMQ-compatible metadata, visible via `job.opts.debounce`, but it does **not** suppress duplicates by itself in the current engine. To actually coalesce rapid duplicates, use `deduplication` with `replace: true` (last write wins). ## Rate limiting Control processing speed: ```typescript const app = new Bunqueue('api', { embedded: true, processor: async (job) => callExternalAPI(job.data), rateLimit: { max: 100, duration: 1000 }, // max 100 jobs per second }); // Per-group limiting (e.g. per customer). With groupKey set, `max` becomes // a per-group concurrency cap (max active jobs per group) and duration is ignored. const app2 = new Bunqueue('api', { embedded: true, processor: async (job) => callAPI(job.data), rateLimit: { max: 10, duration: 1000, groupKey: 'customerId' }, }); // Runtime updates app.setGlobalRateLimit(50, 1000); app.removeGlobalRateLimit(); ``` ```typescript const app = new Bunqueue('api', { embedded: false, processor: async (job) => callExternalAPI(job.data), rateLimit: { max: 100, duration: 1000 }, // max 100 jobs per second }); // Per-group limiting (e.g. per customer). With groupKey set, `max` becomes // a per-group concurrency cap (max active jobs per group) and duration is ignored. const app2 = new Bunqueue('api', { embedded: false, processor: async (job) => callAPI(job.data), rateLimit: { max: 10, duration: 1000, groupKey: 'customerId' }, }); // Runtime updates await app.setGlobalRateLimitAsync(50, 1000); await app.removeGlobalRateLimitAsync(); ``` ```python app = Bunqueue( "api", processor=lambda job: call_external_api(job.data), rate_limit={"max": 100, "duration": 1000}, # max 100 job starts per second ) # Per-group limiting (e.g. per customer). With group_key set, each group gets # its own sliding window: max 10 starts per second per customerId value. app2 = Bunqueue( "api", processor=lambda job: call_api(job.data), rate_limit={"max": 10, "duration": 1000, "group_key": "customerId"}, ) # Runtime updates go through the underlying queue (server-side limit) app.queue.set_global_rate_limit(50, 1000) app.queue.remove_global_rate_limit() ``` *The Python constructor limiter is enforced client-side (sliding window per group); the `app.queue` methods set the server-side queue limit.* Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ## DLQ (Dead Letter Queue) The DLQ collects jobs that failed permanently. Simple Mode can auto-retry and prune it: ```typescript const app = new Bunqueue('critical', { embedded: true, processor: async (job) => riskyOperation(job.data), dlq: { autoRetry: true, // re-queue failed jobs periodically autoRetryInterval: 3600000, // every hour maxAutoRetries: 3, maxAge: 604800000, // purge entries older than 7 days maxEntries: 10000, }, }); // Query const entries = app.getDlq(); const stats = app.getDlqStats(); // { total, byReason, ... } const timeouts = app.getDlq({ reason: 'timeout' }); // Act app.retryDlq(); // retry all app.retryDlq('job-id'); // retry one app.purgeDlq(); // clear all app.setDlqConfig({ autoRetry: false }); ``` ```typescript const app = new Bunqueue('critical', { embedded: false, processor: async (job) => riskyOperation(job.data), dlq: { autoRetry: true, // re-queue failed jobs periodically autoRetryInterval: 3600000, // every hour maxAutoRetries: 3, maxAge: 604800000, // purge entries older than 7 days maxEntries: 10000, }, }); // Query const entries = await app.getDlqAsync(); const stats = await app.getDlqStatsAsync(); // { total, byReason, ... } const timeouts = await app.getDlqAsync({ reason: 'timeout' }); // Act await app.retryDlqAsync(); // retry all await app.retryDlqAsync('job-id'); // retry one await app.purgeDlqAsync(); // clear all await app.setDlqConfigAsync({ autoRetry: false }); ``` ```python app = Bunqueue( "critical", processor=lambda job: risky_operation(job.data), # the config dict travels to the server as-is, so keys stay camelCase dlq={ "autoRetry": True, # re-queue failed jobs periodically "autoRetryInterval": 3600000, # every hour "maxAutoRetries": 3, "maxAge": 604800000, # purge entries older than 7 days "maxEntries": 10000, }, ) # Query and act through the underlying queue entries = app.queue.get_dlq() app.queue.retry_dlq() # retry all app.queue.retry_dlq("job-id") # retry one app.queue.purge_dlq() # clear all app.queue.set_dlq_config({"autoRetry": False}) ``` *`getDlqStats()` and reason filters are not available in the Python SDK yet.* Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). Failure reasons tracked: `explicit_fail`, `max_attempts_exceeded`, `timeout`, `stalled`, `ttl_expired`, `worker_lost`, plus `unknown` as a fallback. ## Cron jobs ```typescript await app.cron('daily-report', '0 9 * * *', { type: 'report' }); await app.cron('eu-digest', '0 8 * * 1', { type: 'weekly' }, { timezone: 'Europe/Rome' }); await app.every('healthcheck', 30000, { type: 'ping' }); await app.listCrons(); await app.removeCron('healthcheck'); ``` ```typescript await app.cron('daily-report', '0 9 * * *', { type: 'report' }); await app.cron('eu-digest', '0 8 * * 1', { type: 'weekly' }, { timezone: 'Europe/Rome' }); await app.every('healthcheck', 30000, { type: 'ping' }); await app.listCrons(); await app.removeCron('healthcheck'); ``` ```python app.cron("daily-report", "0 9 * * *", {"type": "report"}) app.cron("eu-digest", "0 8 * * 1", {"type": "weekly"}, timezone="Europe/Rome") app.every("healthcheck", 30000, {"type": "ping"}) app.list_crons() app.remove_cron("healthcheck") ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). See the [Cron guide](/guide/cron/) for advanced options. ## Events, control, direct access ```typescript // Events (same as Worker) app.on('completed', (job, result) => { }); app.on('failed', (job, error) => { }); // also: active, progress, stalled, error, ready, drained, closed // Control app.pause(); // pause queue + worker app.resume(); // resume both await app.close(); // graceful shutdown await app.close(true); // force shutdown app.isRunning(); app.isPaused(); app.isClosed(); // Escape hatch: the underlying Queue and Worker are yours app.queue.setStallConfig({ stallInterval: 30000 }); app.worker.concurrency = 20; ``` ```typescript // Events (same as Worker) app.on('completed', (job, result) => { }); app.on('failed', (job, error) => { }); // also: active, progress, stalled, error, ready, drained, closed // Control app.pause(); // pause queue + worker app.resume(); // resume both await app.close(); // graceful shutdown await app.close(true); // force shutdown app.isRunning(); app.isPaused(); app.isClosed(); // Escape hatch: the underlying Queue and Worker are yours await app.queue.setStallConfigAsync({ stallInterval: 30000 }); app.worker.concurrency = 20; ``` ```python # Events (same as Worker) app.on("completed", lambda job, result: None) app.on("failed", lambda job, error: None) # also: active, progress, error, ready, drained, closed # Control app.pause() # pause queue + worker app.resume() # resume both app.close() # graceful shutdown app.close(force=True) # force shutdown app.is_running(); app.is_paused(); app.is_closed() # Escape hatch: the underlying Queue and Worker are yours app.queue.set_stall_config({"stallInterval": 30000}) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ## Full example ```typescript import { Bunqueue, shutdownManager } from 'bunqueue/client'; const app = new Bunqueue<{ payload: string }>('my-app', { embedded: true, routes: { 'process': async (job) => ({ id: job.data.payload, status: 'done' }), 'notify': async (job) => ({ sent: true }), 'alert': async (job) => ({ alerted: true }), }, concurrency: 10, retry: { maxAttempts: 3, delay: 1000, strategy: 'jitter' }, circuitBreaker: { threshold: 5, resetTimeout: 30000 }, ttl: { defaultTtl: 600000, perName: { 'verify-otp': 60000 } }, priorityAging: { interval: 60000, minAge: 300000, boost: 1 }, deduplication: { ttl: 5000 }, rateLimit: { max: 100, duration: 1000 }, dlq: { autoRetry: true, maxAge: 604800000 }, }); app.use(async (job, next) => { const start = Date.now(); const result = await next(); console.log(`${job.name}: ${Date.now() - start}ms`); return result; }); app .trigger({ on: 'process', create: 'notify', data: (r) => ({ payload: (r as { id: string }).id }) }) .trigger({ on: 'process', event: 'failed', create: 'alert', data: (_, j) => j.data }); await app.cron('cleanup', '0 2 * * *', { payload: 'nightly' }); await app.add('process', { payload: 'ORD-001' }); process.on('SIGINT', async () => { await app.close(); shutdownManager(); }); ``` ```typescript import { Bunqueue } from 'bunqueue-client'; const app = new Bunqueue<{ payload: string }>('my-app', { embedded: false, routes: { 'process': async (job) => ({ id: job.data.payload, status: 'done' }), 'notify': async (job) => ({ sent: true }), 'alert': async (job) => ({ alerted: true }), }, concurrency: 10, retry: { maxAttempts: 3, delay: 1000, strategy: 'jitter' }, circuitBreaker: { threshold: 5, resetTimeout: 30000 }, ttl: { defaultTtl: 600000, perName: { 'verify-otp': 60000 } }, priorityAging: { interval: 60000, minAge: 300000, boost: 1 }, deduplication: { ttl: 5000 }, rateLimit: { max: 100, duration: 1000 }, dlq: { autoRetry: true, maxAge: 604800000 }, }); app.use(async (job, next) => { const start = Date.now(); const result = await next(); console.log(`${job.name}: ${Date.now() - start}ms`); return result; }); app .trigger({ on: 'process', create: 'notify', data: (r) => ({ payload: (r as { id: string }).id }) }) .trigger({ on: 'process', event: 'failed', create: 'alert', data: (_, j) => j.data }); await app.cron('cleanup', '0 2 * * *', { payload: 'nightly' }); await app.add('process', { payload: 'ORD-001' }); process.on('SIGINT', async () => { await app.close(); }); ``` ```python import signal from bunqueue import Bunqueue app = Bunqueue( "my-app", routes={ "process": lambda job: {"id": job.data["payload"], "status": "done"}, "notify": lambda job: {"sent": True}, "alert": lambda job: {"alerted": True}, }, concurrency=10, retry={"max_attempts": 3, "delay": 1000, "strategy": "jitter"}, circuit_breaker={"threshold": 5, "reset_timeout": 30000}, ttl={"default_ttl": 600000, "per_name": {"verify-otp": 60000}}, priority_aging={"interval": 60000, "min_age": 300000, "boost": 1}, deduplication={"ttl": 5000}, rate_limit={"max": 100, "duration": 1000}, dlq={"autoRetry": True, "maxAge": 604800000}, ) def timing(job, next_fn): result = next_fn() print(f"{job.name} done") return result app.use(timing) app.trigger({ "on": "process", "create": "notify", "data": lambda result, job: {"payload": result["id"]}, }).trigger({ "on": "process", "event": "failed", "create": "alert", "data": lambda error, job: job.data, }) app.cron("cleanup", "0 2 * * *", {"payload": "nightly"}) app.add("process", {"payload": "ORD-001"}) signal.signal(signal.SIGINT, lambda sig, frame: app.close()) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor; see the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ## API reference *The tables below describe the shared TypeScript surface of `bunqueue/client` and `bunqueue-client`. Node.js and Deno use `embedded: false`; await the `Async` variants for authoritative remote queries and mutations. DLQ filtering and statistics come from the broker. The Python SDK mirrors the baseline in snake_case (`get_job_counts`, `set_default_ttl`, `priority_aging`, ...): embedded mode is unavailable, DLQ queries and rate-limit updates live on `app.queue`, and `getDlqStats` is not available yet.* ### Constructor options **Processing mode** (pick one): | Option | Type | Description | |--------|------|-------------| | `processor` | `(job) => Promise` | Single handler | | `routes` | `Record` | Named handlers | | `batch` | `{ size, timeout, processor }` | Batch processing | **Worker:** | Option | Default | Description | |--------|---------|-------------| | `concurrency` | `1` | Parallel jobs | | `embedded` | `false` | Use embedded SQLite (`BUNQUEUE_EMBEDDED=1` forces it on) | | `connection` | localhost:6789 | TCP server connection | | `autorun` | `true` | Start worker immediately | **Features:** | Option | Description | |--------|-------------| | `retry` | `{ maxAttempts, delay, strategy, retryIf, customBackoff }` | | `circuitBreaker` | `{ threshold, resetTimeout, onOpen, onClose, onHalfOpen }` | | `ttl` | `{ defaultTtl, perName }` | | `priorityAging` | `{ interval, minAge, boost, maxPriority, maxScan }` | | `deduplication` | `{ ttl, extend, replace }` | | `debounce` | `{ ttl }` | | `rateLimit` | `{ max, duration, groupKey }` | | `dlq` | `{ autoRetry, autoRetryInterval, maxAutoRetries, maxAge, maxEntries }` | ### Methods | Method | Description | |--------|-------------| | `add(name, data, opts?)` | Add a job | | `addBulk(jobs)` | Add multiple jobs | | `getJob(id)` | Get job by ID | | `getJobCounts()` / `count()` | Job counts | | `use(middleware)` | Add middleware | | `cron(id, pattern, data?, opts?)` | Schedule cron | | `every(id, ms, data?, opts?)` | Schedule interval | | `removeCron(id)` / `listCrons()` | Manage crons | | `cancel(id, grace?)` | Cancel running job | | `isCancelled(id)` / `getSignal(id)` | Cancellation state | | `getCircuitState()` / `resetCircuit()` | Circuit breaker | | `trigger(rule)` | Register event trigger | | `setDefaultTtl(ms)` / `setNameTtl(name, ms)` | TTL updates | | `setDlqConfig(config)` / `getDlqConfig()` | DLQ config | | `getDlq(filter?)` / `getDlqStats()` | Query DLQ | | `retryDlq(id?)` / `purgeDlq()` | DLQ actions | | `setGlobalRateLimit(max, duration?)` | Set rate limit | | `removeGlobalRateLimit()` | Remove rate limit | | `on(event, listener)` / `once()` / `off()` | Events | | `pause()` / `resume()` | Control | | `close(force?)` | Shutdown | ### Properties | Property | Type | Description | |----------|------|-------------| | `name` | `string` | Queue name | | `queue` | `Queue` | Internal Queue | | `worker` | `Worker` | Internal Worker | --- # Workflow Engine for Bun: Durable Multi-Step Jobs Run multi-step processes that survive crashes and undo themselves on failure: saga compensation, human approval gates and durable AI agent loops on SQLite. URL: https://bunqueue.dev/guide/workflow/
guide · workflow engine

Multi-step, with a rollback plan.

Some jobs are really a sequence: reserve the stock, charge the card, send the confirmation. The workflow engine runs that sequence, retries the flaky parts, resumes after a crash, and when a later step fails it undoes the earlier ones for you.

:::caution[Experimental] The workflow engine is **experimental**. Its API can change in a patch release, and this one already does: a `waitFor` inside a `.path()` and a step named after a loop's `name:index` namespace are now rejected at registration instead of being accepted. Both used to be accepted and neither did what it looked like, so the change is a fix, but it is still a change to code that previously registered. Queue, worker, cron, flows and the wire protocol are **not** experimental and follow semver as usual. The workflow engine is a separate `bunqueue/workflow` entrypoint and nothing in the core imports it, so its churn cannot reach them. ::: :::note[Runtime: Bun, in-process] `bunqueue/workflow` is a Bun API. Your step handlers are TypeScript functions the engine calls directly, so unlike the queue there is no wire protocol for it: it is not exposed over TCP and it is not implemented in the Python, PHP, Go, Rust or Elixir clients, nor on Node. Those clients can still push jobs into a queue that a Bun process running a workflow consumes, which is the usual way to drive one from another language. ::: ## The problem it solves Write that sequence as plain code and three things go wrong the first time it breaks in production: 1. **The process dies halfway.** The card was charged, the confirmation never went out, and nothing remembers where it got to. 2. **A later step fails after an earlier one already changed the world.** The stock is reserved for an order that will never exist. 3. **A retry runs the effect twice.** Two charges, one order. A workflow engine exists to make those three cases boring.
reserve stock compensate: release stock
charge card compensate: refund
send confirmation ✗
When `send confirmation` fails, the engine walks back: refund, then release stock. That automatic undo is the **saga pattern**, and each step declares its own inverse with a `compensate` handler. ## The mental model Three ideas carry everything else: **A workflow is a list of nodes.** Steps, branches, loops, approval gates. You describe them; the engine walks them. **Each top-level node gets durable queue delivery.** Finishing one writes its outcome to SQLite and enqueues the next. Inline branch, parallel and loop body steps share that node job but persist their own records. On restart, completed records short-circuit; only work whose outcome is still unknown may replay. **Failure walks the journal backwards.** The engine already knows which steps completed and in what order, so it can undo them in reverse without asking your code to remember anything. ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const flow = new Workflow('checkout') .step('reserve', reserveStock, { compensate: releaseStock }) .step('charge', chargeCard, { compensate: refund }) .step('confirm', sendEmail); const engine = new Engine({ embedded: true, dataPath: './data/wf.db' }); engine.register(flow); await engine.recover(); await engine.start('checkout', { orderId: 'ORD-1' }); ``` Everything runs in your process, on bunqueue's Queue and Worker, persisted to SQLite. No extra services, no YAML, no control plane. For a production service, four details are part of the setup rather than optional tuning: 1. Pass a durable `dataPath`; omitting it creates an in-memory execution store. 2. Register every definition, then call `recover()` during startup. 3. Make externally visible steps idempotent and pass `ctx.idempotencyKey` to providers that support one. 4. Run one workflow `Engine` per process and call `engine.close()` during shutdown. The engine guarantees durable orchestration state, not exactly-once effects in another system. If a process dies after an API accepted a charge but before the completed record reached SQLite, the only safe recovery is to replay the call with the same provider idempotency key. ## Where to go next | | | |---|---| | [Quick Start](/guide/workflow/quickstart/) | Build and run your first workflow | | [Steps & Control Flow](/guide/workflow/steps/) | Context, retries, branching, parallel, loops | | [Rollback](/guide/workflow/rollback/) | Compensation, unwind order, the point of no return | | [Durability](/guide/workflow/durability/) | Idempotency keys, crash recovery, what resumes | | [Human Approval](/guide/workflow/approval/) | Pausing a run until a person decides | | [AI Agents](/guide/workflow/ai-agents/) | Durable agent loops with the Vercel AI SDK | | [API Reference](/guide/workflow/api/) | Engine methods, events, execution shape, limits | ## When *not* to use it | Situation | Use instead | |---|---| | Independent jobs with no ordering | [Queue](/guide/queue/) + [Worker](/guide/worker/) | | Parent/child fan-out without rollback | [Flow Producer](/guide/flow/), lighter | | One queue, one processor, a few routes | [Simple Mode](/guide/simple-mode/) | | Multi-region HA, exactly-once across services | Temporal | :::note[Runtime] The workflow engine ships in the Bun `bunqueue` package only; it is not part of the polyglot [SDKs](/guide/sdks/). From other languages, orchestrate multi-step jobs via [flows](/guide/flow/) or call a Bun service that runs the engine. ::: :::tip[Examples are executable specifications] The complete engine scenarios are mirrored in `test/workflow-docs-examples.test.ts` and run against the real engine. The OpenAI Agents SDK, Claude session seam, Mastra and LangGraph examples are covered by `test/workflow-agent-sdks.test.ts`; the Vercel AI SDK page has both offline model tests and an opt-in live script. Short inspection fragments such as `exec.rollbackStatus` refer to those same tested scenarios rather than inventing separate pseudo-APIs. ::: --- # Queue API: Add and Manage Jobs in Bun The bunqueue Queue is the producer side: create it embedded or connect to a SQLite/PostgreSQL-backed server, then add, inspect, and control jobs. URL: https://bunqueue.dev/guide/queue/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

The producer side.

A Queue is where work goes in. It is the same object whether it writes to SQLite in your process or talks over TCP to a memory-, SQLite-, or PostgreSQL-backed server, so client code does not change when the deployment does.

Start here: create the queue, pick a mode, and know which options belong to the constructor rather than to individual jobs. ## Create a queue ```typescript import { Queue } from 'bunqueue/client'; const queue = new Queue('my-queue', { embedded: true }); await queue.add('job-name', { key: 'value' }); ``` ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('my-queue', { embedded: false }); await queue.add('job-name', { key: 'value' }); ``` ```python from bunqueue import Queue queue = Queue("my-queue") # connects to localhost:6789 queue.add("job-name", {"key": "value"}) ``` ```php use Bunqueue\Queue; $queue = new Queue('my-queue'); // connects to localhost:6789 $queue->add('job-name', ['key' => 'value']); ``` ```go queue := bunqueue.NewQueue("my-queue", bunqueue.Options{}) // localhost:6789 defer queue.Close() queue.Add("job-name", map[string]any{"key": "value"}, nil) ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value}; let queue = Queue::new("my-queue", ConnectionOptions::default()); // localhost:6789 let data = Value::Map(vec![(Value::from("key"), Value::from("value"))]); queue.add("job-name", data, JobOptions::default())?; ``` ```elixir queue = Bunqueue.queue("my-queue") # connects to localhost:6789 {:ok, _job} = Bunqueue.Queue.add(queue, "job-name", %{key: "value"}) ``` :::caution[Embedded vs TCP] `embedded: true` runs the queue inside your process. Without it, the Queue connects to a bunqueue server on `localhost:6789` (see [Server Mode](/guide/server/)). The Queue and its Worker must use the same mode. ::: Useful variations: ```typescript // Typed queue: job.data is type-checked interface TaskData { userId: number; action: string; } const typedQueue = new Queue('tasks', { embedded: true }); // Default options applied to every job const emailQueue = new Queue('emails', { embedded: true, defaultJobOptions: { attempts: 3, backoff: 1000, removeOnComplete: true, }, }); // TCP mode with a custom connection const remoteQueue = new Queue('tasks', { connection: { host: '192.168.1.100', port: 6789, token: 'secret-token', // If AUTH_TOKENS is set on the server poolSize: 4, // Connection pool size }, }); ``` ```typescript // Typed queue: job.data is type-checked interface TaskData { userId: number; action: string; } const typedQueue = new Queue('tasks', { embedded: false }); // Default options applied to every job const emailQueue = new Queue('emails', { embedded: false, defaultJobOptions: { attempts: 3, backoff: 1000, removeOnComplete: true, }, }); // TCP mode with a custom connection const remoteQueue = new Queue('tasks', { connection: { host: '192.168.1.100', port: 6789, token: 'secret-token', // If AUTH_TOKENS is set on the server poolSize: 4, // Connection pool size }, }); ``` ```python from bunqueue import Queue remote_queue = Queue( "tasks", host="192.168.1.100", port=6789, token="secret-token", # If AUTH_TOKENS is set on the server ) # External SDKs take job options on each add. remote_queue.add("send", {"user_id": 42}, attempts=3, remove_on_complete=True) ``` ```php use Bunqueue\Queue; $remoteQueue = new Queue('tasks', [ 'host' => '192.168.1.100', 'port' => 6789, 'token' => 'secret-token', // If AUTH_TOKENS is set on the server ]); $remoteQueue->add('send', ['userId' => 42], [ 'attempts' => 3, 'removeOnComplete' => true, ]); ``` ```go remoteQueue := bunqueue.NewQueue("tasks", bunqueue.Options{ Host: "192.168.1.100", Port: 6789, Token: "secret-token", // If AUTH_TOKENS is set on the server }) defer remoteQueue.Close() remoteQueue.Add("send", map[string]any{"userId": 42}, bunqueue.JobOptions{ "attempts": 3, "removeOnComplete": true, }) ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value}; let remote_queue = Queue::new("tasks", ConnectionOptions { host: "192.168.1.100".into(), port: 6789, token: Some("secret-token".into()), // If AUTH_TOKENS is set on the server ..Default::default() }); remote_queue.add("send", Value::Nil, JobOptions { attempts: Some(3), remove_on_complete: Some(true), ..Default::default() })?; ``` ```elixir remote_queue = Bunqueue.queue("tasks", host: "192.168.1.100", port: 6789, token: "secret-token" # If AUTH_TOKENS is set on the server ) {:ok, _job} = Bunqueue.Queue.add(remote_queue, "send", %{user_id: 42}, attempts: 3, removeOnComplete: true ) ``` _The two TypeScript packages share the same Queue API, including `defaultJobOptions`, `prefixKey`, auto-batching, and nested `connection.poolSize`. Use `embedded: false` on Node.js and Deno; embedded storage requires Bun. In the other SDKs, pass connection options to the constructor (see [SDKs](/guide/sdks/#connection-options)) and job options per `add` call._ ## Where to go next | Guide | What it covers | | ------------------------------------------------------------------------- | --------------------------------------------- | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Groups](/guide/queue/job-groups/) | Per-group priority/FIFO, fairness and capacity | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Worker: Process Jobs from a Bun Queue A bunqueue Worker pulls jobs, runs your processor and acknowledges the result. Concurrency, heartbeats and retries are handled for you, in embedded or TCP mode. URL: https://bunqueue.dev/guide/worker/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Pull, process, ack.

A worker is a loop you do not have to write. It asks the queue for work, runs your function, reports the outcome, and keeps the job alive while it runs.

Give the Worker a queue name and a processor. Whatever the processor returns becomes the job result; whatever it throws becomes a failed attempt. ## Create a worker ```typescript import { Worker } from 'bunqueue/client'; const worker = new Worker('my-queue', async (job) => { // Process the job; the return value is stored as the job's result return { success: true }; }, { embedded: true }); ``` ```typescript import { Worker } from 'bunqueue-client'; const worker = new Worker('my-queue', async (job) => { // Process the job; the return value is stored as the job's result return { success: true }; }, { embedded: false }); ``` ```python from bunqueue import Worker def process(job): # Process the job; the return value is stored as the job's result return {"success": True} worker = Worker("my-queue", process) ``` ```php use Bunqueue\Worker; $worker = new Worker('my-queue', function (Bunqueue\Job $job) { // Process the job; the return value is stored as the job's result return ['success' => true]; }); $worker->run(); // blocking loop; or $worker->runOnce() from a cron tick ``` ```go worker := bunqueue.NewWorker("my-queue", func(job *bunqueue.Job) (any, error) { // Process the job; the return value is stored as the job's result return map[string]any{"success": true}, nil }, bunqueue.WorkerOptions{}) worker.Run() // blocking pull loop ``` ```rust use bunqueue_client::{Value, Worker, WorkerOptions}; let worker = Worker::new( "my-queue", |_job| { // Process the job; the returned Value is stored as the job's result Ok(Value::from(true)) }, WorkerOptions::default(), ); worker.run()?; ``` ```elixir worker = Bunqueue.Worker.new("my-queue", fn _job -> # Process the job; the result is stored as the job's result {:ok, %{success: true}} end) Bunqueue.Worker.run(worker) ``` The Bun, TypeScript, and Python workers start polling immediately (`autorun`); the PHP, Go, Rust, and Elixir workers start when you call their run function. If your processor throws, the job is retried automatically (until the job's `attempts` total executions are used up; the default 3 means 1 run + 2 retries) and then moved to the dead letter queue, a holding area for jobs that keep failing. :::caution[Embedded vs TCP] `embedded: true` runs in-process alongside an embedded Queue and requires Bun. On Node.js and Deno, use `embedded: false` and a TCP connection (default `localhost:6789`). Worker and Queue must use the same mode. Both TypeScript packages share this API; the other SDKs are TCP-only. ::: ## Bun processor contract Both TypeScript packages pass `{ signal }` as the optional second processor argument: ```typescript const worker = new Worker('downloads', async (job, context) => { const response = await fetch(job.data.url, { signal: context?.signal }); return await response.arrayBuffer(); }); ``` Per-job timeout and `worker.cancelJob()` abort this signal. Promise processors remain cooperative: code that ignores the signal continues running, although a late outcome cannot overwrite a broker timeout. The processor may also return a structural Observable; bunqueue stores its final emission, fails on `error` or empty completion, and unsubscribes on abort. No RxJS dependency is required. For BullMQ Pro-oriented imports, `WorkerPro` is an alias of this same `Worker`; `QueuePro`, `QueueEventsPro`, and the `JobPro` type are exported alongside it. The aliases do not create a second implementation or enable telemetry. ## Where to go next | Guide | What it covers | |---|---| | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # CPU-Intensive Workers: Preserve Heartbeats and Locks Run CPU-heavy bunqueue handlers without starving heartbeats: isolate computation, keep lease renewal enabled, and size stall and lock windows for bounded work. URL: https://bunqueue.dev/guide/cpu-intensive-workers/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · cpu-intensive-workers

CPU-intensive work, off the control path.

A long, non-yielding handler can starve the code that renews its lease. Keep the broker safety mechanisms on and move computation to a thread, process, or service that cannot block them.

The important clock is not total job duration. A job can run for hours while heartbeats keep arriving. The dangerous interval is the longest period during which the worker cannot run its heartbeat or lock-renewal path. When that interval exceeds the queue's `stallInterval` or the job lock's TTL, bunqueue may recover and redeliver the job. A stale processor can still finish its local computation, but its expired token cannot safely acknowledge the new processing instance. CPU-heavy handlers must therefore remain idempotent. ## Recommended topology Keep the queue processor responsive and hand CPU work to the runtime's native isolation mechanism. `runCpuTaskOffThread`, `heavy_computation`, and similar names below stand for application code, not bunqueue helpers. ```typescript import { Worker } from 'bunqueue/client'; const worker = new Worker('heavy', async (job) => { // Back this helper with a Bun Worker or a supervised child-process pool. return runCpuTaskOffThread(job.data); }, { connection: { host: '127.0.0.1', port: 6789 }, concurrency: 4, heartbeatInterval: 10_000, lockDuration: 120_000, }); ``` The regular `Worker` owns the TCP connection and lease; the CPU pool does only the computation. The experimental built-in alternative is [`SandboxedWorker`](/guide/worker/sandboxed/). ```typescript import { Worker } from 'bunqueue-client'; const worker = new Worker('heavy', async (job) => { // Back this helper with node:worker_threads or a supervised process pool. return runCpuTaskOffThread(job.data); }, { connection: { host: '127.0.0.1', port: 6789 }, concurrency: 4, heartbeatInterval: 10_000, lockDuration: 120_000, }); ``` The regular `Worker` owns the TCP connection and lease; the CPU pool does only the computation. The experimental built-in alternative is [`SandboxedWorker`](/guide/worker/sandboxed/). ```python from concurrent.futures import ProcessPoolExecutor from bunqueue import Worker pool = ProcessPoolExecutor(max_workers=4) def process(job): return pool.submit(heavy_computation, job.data).result() worker = Worker( "heavy", process, concurrency=4, heartbeat_interval_s=10.0, lock_ttl_ms=120_000, ) worker.run() ``` A process pool avoids the GIL for Python CPU work. The SDK's heartbeat thread remains independent of the process doing the calculation. ```php use Bunqueue\Job; use Bunqueue\Worker; $worker = new Worker('heavy', function (Job $job) { // PHP's worker is sequential: extend before a bounded blocking segment, // or delegate to a child process/service and renew while polling it. $job->extendLock(120_000); return runInChildProcess($job->data()); }, [ 'lockTtlMs' => 120_000, ]); $worker->run(); ``` The PHP worker sends automatic job heartbeats only between callbacks. A single callback that can outlive its extension needs a subprocess polling loop that calls `extendLock()` again, or a larger bounded lease. ```go worker := bunqueue.NewWorker("heavy", func(job *bunqueue.Job) (any, error) { // Processors run in the bounded goroutine pool; heartbeat has its own loop. return heavyComputation(job.Data()) }, bunqueue.WorkerOptions{ Concurrency: 4, LockTtlMs: 120_000, HeartbeatIntervalS: 10, // Go leaves heartbeats off unless enabled }) if err := worker.Run(); err != nil { log.Fatal(err) } ``` Keep `HeartbeatIntervalS` positive for long-running jobs. For native code that blocks the Go runtime, isolate it in a subprocess. ```rust use std::time::Duration; use bunqueue_client::{Worker, WorkerOptions}; let worker = Worker::new("heavy", |job| { // Each processor runs on a worker thread; heartbeat runs independently. heavy_computation(job.data()) }, WorkerOptions { concurrency: 4, lock_ttl_ms: 120_000, heartbeat_interval: Some(Duration::from_secs(10)), ..Default::default() }); worker.run()?; ``` If foreign code can block or abort the process, put that code behind a child process boundary rather than relying only on a Rust thread. ```elixir worker = Bunqueue.Worker.new("heavy", fn job -> {:ok, heavy_computation(job.data)} end, concurrency: 4, lock_ttl: 120_000, heartbeat_interval: 10_000 ) Bunqueue.Worker.run(worker) ``` The SDK heartbeats from a separate BEAM process. Long-running native code must use dirty schedulers or an external port/process so it cannot block the VM's normal schedulers. ## If the loop can yield For computation you control, small cooperative yields can be sufficient. This Bun example yields every 500 iterations so timers and TCP I/O can run: ```typescript async function findNthPrime(n: number): Promise { let count = 0; let candidate = 1; let operations = 0; while (count < n) { candidate++; if (isPrime(candidate)) count++; if (++operations % 500 === 0) await Bun.sleep(0); } return candidate; } ``` Choose the yield frequency by measuring the longest uninterrupted block, not by iteration count alone. A large iteration can itself take longer than the lease window. ## Size the broker policy as a safety margin Offloading is the primary fix. If a bounded segment can still delay ownership traffic, set both of these above its worst-case duration: - the Worker's job-lock TTL (`lockDuration` in Bun, `lockTtlMs` or its language-specific equivalent in network SDKs); - the queue's server-side `stallInterval`. Keep the heartbeat interval comfortably below both. The queue-level policy is shared by every language; configure it as shown in [Stall Detection](/guide/stall-detection/#configuration). :::caution[Do not "fix" CPU stalls by disabling ownership] `heartbeatInterval: 0`, `skipLockRenewal`, and `useLocks: false` remove or weaken recovery signals. They do not make a blocked handler safe and can delay crash recovery or allow duplicate execution. Likewise, `pingInterval` and `commandTimeout` are connection-health settings, not job-processing timeouts. The TCP server does not close a healthy connection merely because it is idle. ::: ## What each timeout controls | Setting | Scope | What happens when it expires | |---|---|---| | `heartbeatInterval` / SDK equivalent | Worker | How often the current lease is renewed; `0` disables renewal where supported | | `lockDuration` / `lockTtlMs` | Job lease | The ownership token becomes eligible for expiry if it is not renewed | | `stallInterval` | Queue policy | A job with no recent heartbeat becomes a stall candidate and may be retried or sent to the DLQ | | `commandTimeout` | TCP command | An unanswered protocol request rejects; repeated command timeouts can reconnect the client | | `pingInterval` | TCP connection | Controls active health probes; it is unrelated to a job's allowed run time | | job `timeout` | Job execution policy | The worker/broker failure path treats the processing attempt as timed out | ## Production checklist - Make the handler idempotent; bunqueue delivery is at least once. - Keep automatic heartbeats and lock renewal enabled. - Offload non-yielding CPU work from the connection/control thread. - Bound each isolated task with an application timeout and supervise its process. - Make lock and stall windows longer than the measured worst uninterrupted block. - Test process death as well as successful completion; recovery behavior matters more than the happy-path benchmark. :::tip[Related] - [Worker Options](/guide/worker/options/) - Worker-side heartbeat and lock settings - [Stall Detection](/guide/stall-detection/) - Broker recovery policy - [SandboxedWorker](/guide/worker/sandboxed/) - experimental thread pool for Bun, Node.js, and Deno - [Monitoring](/guide/monitoring/) - Observe active, stalled, failed, and DLQ jobs ::: --- # QueueGroup: Namespace Related Queues Group related bunqueue queues under a shared prefix. Useful for multi-tenant apps and per-domain queue organization. URL: https://bunqueue.dev/guide/queue-group/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue-group

Many queues, one namespace.

QueueGroup prefixes a set of queues with a shared name, so "invoices" inside the "billing" group becomes "billing:invoices". Handy for multi-tenant apps and keeping domains apart.

A QueueGroup is a thin organizer: it creates normal `Queue` and `Worker` instances whose names carry the group prefix, and it can pause, resume, or clear all of them at once. :::note[Availability] `QueueGroup` ships in the Bun package (`bunqueue/client`). From the other SDKs, create the queues individually with the prefixed name (for example `new Queue('billing:invoices')`): the prefix is just part of the queue name on the server, so grouped and non-grouped clients interoperate on the same queues. ::: ## Quick Start ```typescript import { QueueGroup } from 'bunqueue/client'; const billing = new QueueGroup('billing'); // Queues are automatically prefixed const invoices = billing.getQueue('invoices', { embedded: true }); // "billing:invoices" const payments = billing.getQueue('payments', { embedded: true }); // "billing:payments" await invoices.add('create', { amount: 100 }); await payments.add('process', { orderId: '123' }); // Workers use the same prefixed names const invoiceWorker = billing.getWorker('invoices', async (job) => { console.log('Processing invoice:', job.data); return { processed: true }; }, { embedded: true }); ``` ```typescript import { QueueGroup } from 'bunqueue-client'; const billing = new QueueGroup('billing'); // Queues are automatically prefixed const invoices = billing.getQueue('invoices', { embedded: false }); // "billing:invoices" const payments = billing.getQueue('payments', { embedded: false }); // "billing:payments" await invoices.add('create', { amount: 100 }); await payments.add('process', { orderId: '123' }); // Workers use the same prefixed names const invoiceWorker = billing.getWorker('invoices', async (job) => { console.log('Processing invoice:', job.data); return { processed: true }; }, { embedded: false }); ``` ```python from bunqueue import Queue, Worker invoices = Queue("billing:invoices") payments = Queue("billing:payments") invoices.add("create", {"amount": 100}) payments.add("process", {"order_id": "123"}) invoice_worker = Worker( "billing:invoices", lambda job: {"processed": True, "invoice": job.data}, ) invoice_worker.run() # blocking loop ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; $invoices = new Queue('billing:invoices'); $payments = new Queue('billing:payments'); $invoices->add('create', ['amount' => 100]); $payments->add('process', ['orderId' => '123']); $invoiceWorker = new Worker('billing:invoices', fn (Bunqueue\Job $job) => ['processed' => true, 'invoice' => $job->data()] ); $invoiceWorker->run(); // blocking loop ``` ```go invoices := bunqueue.NewQueue("billing:invoices", bunqueue.Options{}) payments := bunqueue.NewQueue("billing:payments", bunqueue.Options{}) invoices.Add("create", map[string]any{"amount": 100}, nil) payments.Add("process", map[string]any{"orderId": "123"}, nil) invoiceWorker := bunqueue.NewWorker("billing:invoices", processor, bunqueue.WorkerOptions{}) invoiceWorker.Run() // blocking loop ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions}; let options = ConnectionOptions::default(); let invoices = Queue::new("billing:invoices", options.clone()); let payments = Queue::new("billing:payments", options); invoices.add("create", Value::from(100), JobOptions::default())?; payments.add("process", Value::from("123"), JobOptions::default())?; let invoice_worker = Worker::new( "billing:invoices", processor, WorkerOptions::default(), ); invoice_worker.run()?; // blocking loop ``` ```elixir invoices = Bunqueue.queue("billing:invoices") payments = Bunqueue.queue("billing:payments") {:ok, _job} = Bunqueue.Queue.add(invoices, "create", %{amount: 100}) {:ok, _job} = Bunqueue.Queue.add(payments, "process", %{order_id: "123"}) invoice_worker = Bunqueue.Worker.new("billing:invoices", fn job -> {:ok, %{processed: true, invoice: job.data}} end) Bunqueue.Worker.run(invoice_worker) # blocking loop ``` Both TypeScript packages expose `QueueGroup`; `getQueue` and `getWorker` accept the same options as `Queue` and `Worker`. Node.js and Deno use TCP. Use the `Async` group methods to act on registered remote queues. The other SDK examples create normal queues and workers with the same prefixed names. ## Common Tasks ### Operate on the whole group ```typescript billing.listQueues(); // ['invoices', 'payments'] (names without prefix) billing.pauseAll(); // pause every queue in the group billing.resumeAll(); // resume them billing.drainAll(); // remove all waiting jobs billing.obliterateAll(); // remove ALL data from every queue // Awaitable forms are authoritative in embedded and TCP modes await billing.pauseAllAsync(); await billing.resumeAllAsync(); const removed = await billing.drainAllAsync(); await billing.obliterateAllAsync(); ``` ```typescript const names = await billing.listQueuesAsync(); // registered remote queue names await billing.pauseAllAsync(); await billing.resumeAllAsync(); const removed = await billing.drainAllAsync(); await billing.obliterateAllAsync(); ``` ```python queues = [invoices, payments] for queue in queues: queue.pause() for queue in queues: queue.resume() removed = [queue.drain() for queue in queues] ``` ```php $queues = [$invoices, $payments]; foreach ($queues as $queue) { $queue->pause(); } foreach ($queues as $queue) { $queue->resume(); } $removed = array_map(fn ($queue) => $queue->drain(), $queues); ``` ```go queues := []*bunqueue.Queue{invoices, payments} for _, queue := range queues { if err := queue.Pause(); err != nil { return err } } for _, queue := range queues { if err := queue.Resume(); err != nil { return err } } ``` ```rust let queues = [&invoices, &payments]; for queue in queues { queue.pause()?; } for queue in queues { queue.resume()?; } ``` ```elixir queues = [invoices, payments] Enum.each(queues, fn queue -> :ok = Bunqueue.Queue.pause(queue) end) Enum.each(queues, fn queue -> :ok = Bunqueue.Queue.resume(queue) end) {:ok, removed} = Enum.reduce_while(queues, {:ok, []}, fn queue, {:ok, counts} -> case Bunqueue.Queue.drain(queue) do {:ok, count} -> {:cont, {:ok, [count | counts]}} {:error, error} -> {:halt, {:error, error}} end end) ``` :::note[Synchronous and awaitable forms] `listQueues()` and the synchronous bulk operations use the in-process embedded manager. For TCP queues, use `listQueuesAsync`, `pauseAllAsync`, `resumeAllAsync`, `drainAllAsync`, and `obliterateAllAsync`; they operate on every queue created through the group and wait for completion. ::: ### Isolate tenants ```typescript const tenantA = new QueueGroup('tenant-a'); const tenantB = new QueueGroup('tenant-b'); const tasksA = tenantA.getQueue('tasks', { embedded: true }); // "tenant-a:tasks" const tasksB = tenantB.getQueue('tasks', { embedded: true }); // "tenant-b:tasks" ``` ```typescript const tenantA = new QueueGroup('tenant-a'); const tenantB = new QueueGroup('tenant-b'); const tasksA = tenantA.getQueue('tasks', { embedded: false }); // "tenant-a:tasks" const tasksB = tenantB.getQueue('tasks', { embedded: false }); // "tenant-b:tasks" ``` ```python tasks_a = Queue("tenant-a:tasks") tasks_b = Queue("tenant-b:tasks") ``` ```php $tasksA = new Queue('tenant-a:tasks'); $tasksB = new Queue('tenant-b:tasks'); ``` ```go tasksA := bunqueue.NewQueue("tenant-a:tasks", bunqueue.Options{}) tasksB := bunqueue.NewQueue("tenant-b:tasks", bunqueue.Options{}) ``` ```rust let tasks_a = Queue::new("tenant-a:tasks", ConnectionOptions::default()); let tasks_b = Queue::new("tenant-b:tasks", ConnectionOptions::default()); ``` ```elixir tasks_a = Bunqueue.queue("tenant-a:tasks") tasks_b = Bunqueue.queue("tenant-b:tasks") ``` ### Separate environments ```typescript const env = process.env.NODE_ENV || 'development'; const group = new QueueGroup(`${env}-tasks`); const queue = group.getQueue('jobs', { embedded: true }); // "development-tasks:jobs" or "production-tasks:jobs" ``` ```typescript const env = process.env.NODE_ENV || 'development'; const group = new QueueGroup(`${env}-tasks`); const queue = group.getQueue('jobs', { embedded: false }); // "development-tasks:jobs" or "production-tasks:jobs" ``` ```python env = os.getenv("APP_ENV", "development") queue = Queue(f"{env}-tasks:jobs") ``` ```php $env = getenv('APP_ENV') ?: 'development'; $queue = new Queue("{$env}-tasks:jobs"); ``` ```go env := os.Getenv("APP_ENV") if env == "" { env = "development" } queue := bunqueue.NewQueue(env+"-tasks:jobs", bunqueue.Options{}) ``` ```rust let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()); let queue = Queue::new(format!("{env}-tasks:jobs"), ConnectionOptions::default()); ``` ```elixir env = System.get_env("APP_ENV", "development") queue = Bunqueue.queue("#{env}-tasks:jobs") ``` ## Methods Reference | Method | Description | |--------|-------------| | `getQueue(name, opts?)` | Get a queue within the group (embedded or TCP) | | `getWorker(name, processor, opts?)` | Create a worker for a queue in the group (embedded or TCP) | | `listQueues()` | List queue names in the group, without prefix (embedded only) | | `pauseAll()` | Pause all queues in the group (embedded only) | | `resumeAll()` | Resume all queues in the group (embedded only) | | `drainAll()` | Remove waiting jobs from all queues (embedded only) | | `obliterateAll()` | Remove all data from all queues (embedded only) | | `listQueuesAsync()` | List tracked group queues in either runtime | | `pauseAllAsync()` / `resumeAllAsync()` | Await group control in either runtime | | `drainAllAsync()` | Drain all tracked queues and return the aggregate count | | `obliterateAllAsync()` | Await removal of all tracked queue data | :::tip[Related Guides] - [Queue API](/guide/queue/) - Options accepted by `getQueue` - [Worker API](/guide/worker/) - Options accepted by `getWorker` ::: --- # Flow Producer: Parent and Child Jobs in Bun Build durable job graphs in bunqueue: children run first, then the parent reads their results. Flows are atomic in SQLite and PostgreSQL modes. URL: https://bunqueue.dev/guide/flow/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · flow producer

Jobs that wait for each other.

Some work only makes sense in order: resize every image, then build the album; charge each line, then close the invoice. A flow declares that shape once and bunqueue holds the parent until its children are done.

A flow is a tree of jobs. Children are queued immediately, the parent stays blocked until every child has completed, and then runs with access to what they returned. ## Quick Start The most common shape: a parent job that waits for its children. Children run first, then the parent runs with access to their results: ```typescript import { FlowProducer, Worker } from 'bunqueue/client'; type ReportData = { month?: string; source?: 'sales' | 'costs'; }; const flow = new FlowProducer({ embedded: true }); const rowsBySource = { sales: [120, 80], costs: [50, 20] }; const worker = new Worker( 'reports', async (job) => { if (job.name === 'build-report') { const values = await job.getChildrenValues<{ rows: number[] }>(); return { report: Object.values(values) }; } if (!job.data.source) throw new Error('source is required'); return { rows: rowsBySource[job.data.source] }; }, { embedded: true } ); const node = await flow.add({ name: 'build-report', queueName: 'reports', data: { month: '2026-01' }, children: [ { name: 'fetch-sales', queueName: 'reports', data: { source: 'sales' } }, { name: 'fetch-costs', queueName: 'reports', data: { source: 'costs' } }, ], }); const result = await node.job.waitUntilFinished(null, 10_000); console.log(result); await worker.close(); await flow.close(); ``` ```typescript import { FlowProducer, Worker } from 'bunqueue-client'; const flow = new FlowProducer(); await flow.add({ name: 'build-report', queueName: 'reports', data: { month: '2026-01' }, children: [ { name: 'fetch-sales', queueName: 'reports', data: { source: 'sales' } }, { name: 'fetch-costs', queueName: 'reports', data: { source: 'costs' } }, ], }); new Worker('reports', async (job) => { if (job.name === 'build-report') { // Children have completed; read their results const values = await job.getChildrenValues(); return { report: Object.values(values) }; } return { rows: await fetchData(job.data.source) }; }); ``` ```python from bunqueue import FlowProducer, Worker flow = FlowProducer() flow.add({ "name": "build-report", "queueName": "reports", "data": {"month": "2026-01"}, "children": [ {"name": "fetch-sales", "queueName": "reports", "data": {"source": "sales"}}, {"name": "fetch-costs", "queueName": "reports", "data": {"source": "costs"}}, ], }) def process(job): if job.name == "build-report": # Children have completed; read their results values = job.get_children_values() return {"report": list(values.values())} return {"rows": fetch_data(job.data["source"])} Worker("reports", process).run() ``` ```php use Bunqueue\FlowProducer; use Bunqueue\Queue; use Bunqueue\Worker; $flow = new FlowProducer(); $flow->add([ 'name' => 'build-report', 'queueName' => 'reports', 'data' => ['month' => '2026-01'], 'children' => [ ['name' => 'fetch-sales', 'queueName' => 'reports', 'data' => ['source' => 'sales']], ['name' => 'fetch-costs', 'queueName' => 'reports', 'data' => ['source' => 'costs']], ], ]); $queue = new Queue('reports'); $worker = new Worker('reports', function (Bunqueue\Job $job) use ($queue) { if ($job->name() === 'build-report') { // Children have completed; read their results $values = $queue->getChildrenValues($job->id()); return ['report' => array_values($values)]; } return ['rows' => fetchData($job->data()['source'])]; }); $worker->run(); ``` ```go flow := bunqueue.NewFlowProducer(bunqueue.Options{}) defer flow.Close() node, err := flow.Add(bunqueue.FlowJob{ Name: "build-report", QueueName: "reports", Data: map[string]any{"month": "2026-01"}, Children: []bunqueue.FlowJob{ {Name: "fetch-sales", QueueName: "reports", Data: map[string]any{"source": "sales"}}, {Name: "fetch-costs", QueueName: "reports", Data: map[string]any{"source": "costs"}}, }, }) queue := bunqueue.NewQueue("reports", bunqueue.Options{}) worker := bunqueue.NewWorker("reports", func(job *bunqueue.Job) (any, error) { if job.Name() == "build-report" { // Children have completed; read their results values, err := queue.GetChildrenValues(job.ID()) if err != nil { return nil, err } return map[string]any{"report": values}, nil } return fetchData(job.Data()["source"].(string)) }, bunqueue.WorkerOptions{}) worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, FlowJob, FlowProducer, JobOptions, Value}; let flow = FlowProducer::new(ConnectionOptions::default()); let child = |name: &str, source: &str| FlowJob { name: name.into(), queue_name: "reports".into(), data: Value::Map(vec![(Value::from("source"), Value::from(source))]), options: JobOptions::default(), children: vec![], }; let node = flow.add(FlowJob { name: "build-report".into(), queue_name: "reports".into(), data: Value::Map(vec![(Value::from("month"), Value::from("2026-01"))]), options: JobOptions::default(), children: vec![child("fetch-sales", "sales"), child("fetch-costs", "costs")], })?; ``` _Reading children results (`GetChildrenValues`) has no typed helper in the Rust SDK yet; use the documented wire protocol until it is added._ ```elixir flow = Bunqueue.FlowProducer.new() {:ok, node} = Bunqueue.FlowProducer.add(flow, %{ name: "build-report", queue: "reports", data: %{month: "2026-01"}, children: [ %{name: "fetch-sales", queue: "reports", data: %{source: "sales"}}, %{name: "fetch-costs", queue: "reports", data: %{source: "costs"}} ] }) ``` _Reading children results (`GetChildrenValues`) has no typed helper in the Elixir SDK yet; use the documented wire protocol until it is added._ Flow creation is one broker-side transaction in the Bun package and all six current external SDKs: `addBulk` commits every tree or none, and workers cannot see a leaf before the full graph exists. Previously published SDK versions that compose `PUSH` and `UpdateParent` remain compatible with the server, but their already-sent requests cannot gain `PUSHF` all-or-nothing visibility. :::tip In the Bun package, FlowProducer works in TCP mode too: pass `connection: { port: 6789 }` instead of `embedded: true`. ::: ### Creation guarantees and limits The Bun producer validates the complete graph before sending it and the broker validates it again. With a SQLite `dataPath`, one immediate transaction commits the graph before it is published to workers, even when individual nodes omit `durable: true`. PostgreSQL likewise admits the complete graph, dependency edges, queue registry changes, and durable events in one database transaction before any broker can claim a leaf. Without either persistent backend, embedded mode is intentionally memory-only: creation is still atomically visible to workers, but a process crash cannot recover it. - A flow may contain at most 10,000 jobs, at most 10 MB of data per job and 64 MB across the batch. A root is depth 0; descendants may be at most 100 edges below it. - `jobId` is allowed, but cannot be empty or contain `:`. Reusing any existing or retained flow ID—including durable job/DLQ rows, completion or timeout tombstones, retained results, and IDs still referenced by a waiting parent—rejects the whole request in either persistent backend. - `name` inside user data is preserved independently from the job's own name. Keys beginning with `__` are reserved for engine-owned flow metadata. - `repeat`, `deduplication`, `debounce`, and `opts.parent` are rejected inside an atomic flow because their independent lifetime/ownership semantics cannot participate safely in the graph transaction. - `opts.group` is preserved for every node, including `id`, BullMQ Pro intra-group `priority`, and `maxSize`. Group options are validated with the rest of the graph; an invalid option or full group rejects every node before either SQLite or PostgreSQL publishes a partial flow. - The four child-failure policies are mutually exclusive. Wire values are checked at runtime too: IDs must be strings, link fields must be string arrays, booleans must actually be booleans, and parent metadata must match both sides of every edge. These checks happen before any queue counter, heap, dependency index, or selected-backend row changes. :::note When a child uses `removeOnComplete`, bunqueue retains a payload-free completion proof so a restart cannot strand its parent. Unreferenced proofs follow `maxCompletedJobs`; a proof referenced by a waiting parent stays pinned until that parent's durable promotion or removal releases the final dependency edge. It does not retain the child Job or result. Once a parent is promoted, its persisted ready state remains authoritative even after the proof expires. Use retained children—not `removeOnComplete`—when a parent must read results after a broker restart. ::: ## Where to go next | | | | ------------------------------------------------- | ----------------------------------------------------- | | [Flow Patterns](/guide/flow/patterns/) | Chains, fan-in, trees, reading child results, options | | [Flow Failure Handling](/guide/flow/failures/) | What a parent does when a child dies for good | | [Flow Producer Reference](/guide/flow/reference/) | Every producer method, job helper and step field | --- # Stall Detection: Auto-Recover Unresponsive Jobs bunqueue stall detection auto-recovers stuck jobs. It is on by default; tune heartbeat intervals, max stall thresholds, and grace periods for long jobs. URL: https://bunqueue.dev/guide/stall-detection/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · stall-detection

Stuck jobs come back.

If a worker crashes or hangs mid-job, the job is not lost. bunqueue notices the silence, retries the job, and parks repeat offenders in the dead letter queue.

30s+ of heartbeat silence marks a job stalled (confirmed over two 5s sweeps, so ~35–40s in practice) 3 stalls before a job moves to the DLQ 5s grace period after a job starts
While a worker processes a job it sends periodic **heartbeats**, small "I'm still alive" signals. If heartbeats stop (crashed process, hung code, dead network), the job is **stalled**: bunqueue re-queues it for another worker, and after too many stalls moves it to the [dead letter queue](/guide/dlq/) (DLQ), the holding area for jobs that keep failing. **Stall detection is on by default with sensible defaults.** A job may run for hours without stalling as long as automatic heartbeats continue. Tune these thresholds when one uninterrupted work segment can block heartbeats for more than 30 seconds, or when you need a different recovery budget. Detection is poll-driven and two-phase: a job must exceed `stallInterval` in two consecutive 5-second sweeps before it is marked stalled, so the earliest detection is roughly `stallInterval` plus one to two sweeps (~35–40s at the defaults). ## Configuration ```typescript import { Queue } from 'bunqueue/client'; const queue = new Queue('my-queue', { embedded: true }); queue.setStallConfig({ enabled: true, // on by default stallInterval: 30000, // stalled after 30s without a heartbeat maxStalls: 3, // move to DLQ after 3 stalls gracePeriod: 5000, // no stall checks in the first 5s of a job }); ``` ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('my-queue', { embedded: false }); await queue.setStallConfigAsync({ enabled: true, // on by default stallInterval: 30000, // stalled after 30s without a heartbeat maxStalls: 3, // move to DLQ after 3 stalls gracePeriod: 5000, // no stall checks in the first 5s of a job }); ``` ```python from bunqueue import Queue queue = Queue("my-queue") queue.set_stall_config({ "enabled": True, # on by default "stallInterval": 30000, # stalled after 30s without a heartbeat "maxStalls": 3, # move to DLQ after 3 stalls "gracePeriod": 5000, # no stall checks in the first 5s of a job }) ``` The PHP SDK does not expose this broker command yet. Set the same queue policy through the HTTP API: ```bash curl -X PUT http://localhost:6790/queues/my-queue/stall-config \ -H 'content-type: application/json' \ -d '{"config":{"enabled":true,"stallInterval":30000,"maxStalls":3,"gracePeriod":5000}}' ``` The Go SDK does not expose this broker command yet. Set the same queue policy through the HTTP API: ```bash curl -X PUT http://localhost:6790/queues/my-queue/stall-config \ -H 'content-type: application/json' \ -d '{"config":{"enabled":true,"stallInterval":30000,"maxStalls":3,"gracePeriod":5000}}' ``` The Rust SDK does not expose this broker command yet. Set the same queue policy through the HTTP API: ```bash curl -X PUT http://localhost:6790/queues/my-queue/stall-config \ -H 'content-type: application/json' \ -d '{"config":{"enabled":true,"stallInterval":30000,"maxStalls":3,"gracePeriod":5000}}' ``` The Elixir SDK does not expose this broker command yet. Set the same queue policy through the HTTP API: ```bash curl -X PUT http://localhost:6790/queues/my-queue/stall-config \ -H 'content-type: application/json' \ -d '{"config":{"enabled":true,"stallInterval":30000,"maxStalls":3,"gracePeriod":5000}}' ``` *The stall policy is server-side state per queue: a policy set from any client (or the HTTP API) governs jobs processed by workers in every language. The stall-config helper ships in the Bun, TypeScript, and Python clients; the PHP, Go, Rust, and Elixir SDKs do not expose one yet.* | Option | Default | Description | |--------|---------|-------------| | `enabled` | `true` | Enable/disable stall detection | | `stallInterval` | `30000` | Time (ms) without a heartbeat before a job is stalled | | `maxStalls` | `3` | Max stalls before moving to DLQ | | `gracePeriod` | `5000` | Initial grace period (ms) after a job starts | On the worker side, heartbeats are automatic: ```typescript const worker = new Worker('queue', processor, { embedded: true, heartbeatInterval: 10000, // heartbeat every 10 seconds (default) }); ``` ```typescript const worker = new Worker('queue', processor, { embedded: false, heartbeatInterval: 10000, // heartbeat every 10 seconds (default) }); ``` ```python worker = Worker("queue", process, heartbeat_interval_s=10.0) # default; 0 disables ``` ```php $worker = new Worker('queue', $processor, [ 'heartbeatIntervalS' => 10.0, // Fires between jobs (sequential worker) ]); ``` ```go worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{ HeartbeatIntervalS: 10, // Heartbeats are disabled by default in Go }) ``` ```rust let worker = Worker::new("queue", processor, WorkerOptions { heartbeat_interval: Some(Duration::from_secs(10)), // None disables ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("queue", handler, heartbeat_interval: 10_000) # ms ``` Keep `heartbeatInterval` well below `stallInterval`, otherwise healthy jobs get flagged as stalled. With SQLite or PostgreSQL persistence enabled, a custom stall policy and every job's cumulative stall count survive process and broker restarts. A crash consumes one `attempts` slot and one `stallCount` slot; reaching either `maxAttempts` or `maxStalls` is terminal and moves the job to the DLQ. Repeated crashes therefore cannot reset either retry budget. ## Long-running jobs A long total runtime is safe with heartbeats. Use a wider stall window when a single processing segment can block the runtime or network long enough to miss the default 30-second window: ```typescript // Video processing may take hours const videoQueue = new Queue('video-processing', { embedded: true }); videoQueue.setStallConfig({ stallInterval: 300000, // 5 minutes maxStalls: 2, gracePeriod: 60000, }); const worker = new Worker('video-processing', async (job) => { for (const chunk of video.chunks) { await processChunk(chunk); await job.updateProgress(chunk.progress); // also counts as a heartbeat } }, { embedded: true, heartbeatInterval: 30000 }); ``` ```typescript // Video processing may take hours const videoQueue = new Queue('video-processing', { embedded: false }); await videoQueue.setStallConfigAsync({ stallInterval: 300000, // 5 minutes maxStalls: 2, gracePeriod: 60000, }); const worker = new Worker('video-processing', async (job) => { for (const chunk of video.chunks) { await processChunk(chunk); await job.updateProgress(chunk.progress); // also counts as a heartbeat } }, { embedded: false, heartbeatInterval: 30000 }); ``` ```python # Video processing may take hours video_queue = Queue("video-processing") video_queue.set_stall_config({ "stallInterval": 300000, # 5 minutes "maxStalls": 2, "gracePeriod": 60000, }) def process(job): for chunk in video.chunks: process_chunk(chunk) job.update_progress(chunk.progress) # also counts as a heartbeat worker = Worker("video-processing", process, heartbeat_interval_s=30.0) ``` Configure the five-minute broker policy through the HTTP API shown above, then report progress from the sequential handler so it also refreshes the stall timer: ```php $worker = new Worker('video-processing', function (Bunqueue\Job $job) use ($video) { foreach ($video->chunks as $chunk) { processChunk($chunk); $job->updateProgress($chunk->progress); } }, ['lockTtlMs' => 300000]); ``` Configure the five-minute broker policy through the HTTP API shown above. The worker heartbeat and progress updates both refresh liveness: ```go worker := bunqueue.NewWorker("video-processing", func(job *bunqueue.Job) (any, error) { for _, chunk := range video.Chunks { processChunk(chunk) if err := job.UpdateProgress(chunk.Progress, ""); err != nil { return nil, err } } return nil, nil }, bunqueue.WorkerOptions{HeartbeatIntervalS: 30, LockTtlMs: 300_000}) ``` Configure the five-minute broker policy through the HTTP API shown above. The worker heartbeat and progress updates both refresh liveness: ```rust let worker = Worker::new("video-processing", move |job| { for chunk in &video.chunks { process_chunk(chunk); job.update_progress(chunk.progress, None) .map_err(|error| ProcessError::retryable(error.to_string()))?; } Ok(Value::Nil) }, WorkerOptions { heartbeat_interval: Some(Duration::from_secs(30)), lock_ttl_ms: 300_000, ..Default::default() }); ``` Configure the five-minute broker policy through the HTTP API shown above. The worker heartbeat and progress updates both refresh liveness: ```elixir handler = fn job -> Enum.each(video.chunks, fn chunk -> process_chunk(chunk) {:ok, _} = Bunqueue.Job.update_progress(job, chunk.progress) end) {:ok, nil} end worker = Bunqueue.Worker.new("video-processing", handler, heartbeat_interval: 30_000, lock_ttl: 300_000 ) ``` Two things reset the stall timer: the worker's automatic heartbeat (every `heartbeatInterval` ms) and any `job.updateProgress()` call. For long jobs without natural progress points, the automatic heartbeat is enough. ## What happens when a job stalls 1. **Retry**: the path depends on how the stall was detected. Heartbeat-stall recovery re-queues the job with its stall count incremented and `runAt` pushed out by the job's exponential backoff, without waking blocked pullers, so pickup waits for the backoff plus the next poll. Lock-expiry recovery re-queues without backoff and notifies waiting workers immediately. 2. **DLQ**: the job becomes terminal when either its cumulative stall count reaches `maxStalls` or the interrupted delivery consumes its final normal `attempts` slot. The attempts check wins ties: since each stall also consumes an attempt, with the defaults (`maxStalls: 3`, `attempts: 3`) a repeatedly stalling job lands in the DLQ as `max_attempts_exceeded`. The `stalled` classification appears only when `maxStalls` is lower than the job's remaining `attempts` budget. ## Listening for stalls The Bun package can listen through embedded or TCP `QueueEvents`: ```typescript import { QueueEvents } from 'bunqueue/client'; const events = new QueueEvents('my-queue', { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }); await events.waitUntilReady(); events.on('stalled', ({ jobId }) => { console.log(`Job ${jobId} stalled`); }); ``` ```typescript import { QueueEvents } from 'bunqueue-client'; const events = new QueueEvents('my-queue', { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }); await events.waitUntilReady(); events.on('stalled', ({ jobId }) => { console.log(`Job ${jobId} stalled`); }); ``` The network SDK does not receive broker-side stall events. Subscribe to the queue's SSE stream and handle frames whose SSE `event` name is `job:stalled` (the JSON `data` carries `queue`, `jobId`, `timestamp`): ```bash curl -N http://localhost:6790/events/queues/my-queue ``` The network SDK does not receive broker-side stall events. Subscribe to the queue's SSE stream and handle frames whose SSE `event` name is `job:stalled` (the JSON `data` carries `queue`, `jobId`, `timestamp`): ```bash curl -N http://localhost:6790/events/queues/my-queue ``` The network SDK does not receive broker-side stall events. Subscribe to the queue's SSE stream and handle frames whose SSE `event` name is `job:stalled` (the JSON `data` carries `queue`, `jobId`, `timestamp`): ```bash curl -N http://localhost:6790/events/queues/my-queue ``` The network SDK does not receive broker-side stall events. Subscribe to the queue's SSE stream and handle frames whose SSE `event` name is `job:stalled` (the JSON `data` carries `queue`, `jobId`, `timestamp`): ```bash curl -N http://localhost:6790/events/queues/my-queue ``` The network SDK does not receive broker-side stall events. Subscribe to the queue's SSE stream and handle frames whose SSE `event` name is `job:stalled` (the JSON `data` carries `queue`, `jobId`, `timestamp`): ```bash curl -N http://localhost:6790/events/queues/my-queue ``` The shared TypeScript `Worker` also emits `stalled` in embedded and TCP modes. Its TCP path uses the same dedicated authenticated broker subscription as QueueEvents and re-subscribes after reconnect. The other language SDKs should use SSE or WebSocket for this broker-side event. Stall webhooks are not emitted, so do not register `job.stalled` as a webhook event. This notification is preserved when an expired lease consumes the final `maxStalls` or `maxAttempts` slot: the broker publishes `stalled` before the terminal `failed` queue event and moves the job to the DLQ. ## Monitoring ```typescript const stats = queue.getDlqStats(); console.log('Stalled jobs in DLQ:', stats.byReason.stalled); const stalledJobs = queue.getDlq({ reason: 'stalled' }); ``` ```typescript const stats = await queue.getDlqStatsAsync(); console.log('Stalled jobs in DLQ:', stats.byReason.stalled); const stalledJobs = await queue.getDlqAsync({ reason: 'stalled' }); ``` ```python # get_dlq() returns raw jobs without a `reason` field. # Use the Bun client to filter by reason. jobs = queue.get_dlq() ``` ```php // getDlq() returns raw jobs without a `reason` field. // Use the Bun client to filter by reason. $jobs = $queue->getDlq(); ``` ```go // GetDlq returns raw jobs without a `reason` field. // Use the Bun client to filter by reason. jobs, err := queue.GetDlq(0) // 0 means no explicit count bound ``` ```rust // get_dlq returns raw jobs without a `reason` field. // Use the Bun client to filter by reason. let jobs = queue.get_dlq(None)?; ``` ```elixir # dlq/1 returns raw jobs without a `reason` field. # Use the Bun client to filter by reason. {:ok, jobs} = Bunqueue.Queue.dlq(queue) ``` *Both TypeScript packages expose authoritative `getDlqStatsAsync()` and `getDlqAsync({ reason })` over TCP. The other SDKs list raw DLQ jobs with `getDlq(count?)` / `get_dlq()` / `GetDlq(count)` and do not expose the entry's failure-reason metadata through those helpers.* ## SandboxedWorker :::caution[Experimental] `SandboxedWorker` depends on experimental Bun Workers. For production, use the standard `Worker`. See [Worker vs SandboxedWorker](/guide/worker/sandboxed/#worker-vs-sandboxedworker). ::: `SandboxedWorker` also sends heartbeats automatically in both modes; in embedded mode `heartbeatInterval` defaults to `5000` ms. If its jobs run longer than `stallInterval`, either raise `stallInterval`, call `progress()` periodically, or disable stall detection with `queue.setStallConfig({ enabled: false })`. :::tip[Related Guides] - [Dead Letter Queue](/guide/dlq/) - Where stalled jobs end up after max stalls - [Worker API](/guide/worker/) - Configure heartbeat intervals - [CPU-Intensive Workers](/guide/cpu-intensive-workers/) - Prevent stalls in CPU-heavy workloads ::: --- # Dead Letter Queue: What Happens to Failed Jobs bunqueue keeps terminally failed jobs in a Dead Letter Queue with failure metadata. Inspect them, retry manually, and configure retention. URL: https://bunqueue.dev/guide/dlq/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · dead letter queue

Failed jobs, kept in the DLQ.

When a job runs out of retries, bunqueue can retain it in the Dead Letter Queue with the terminal error and attempt metadata, so you can inspect it and retry it deliberately.

The Dead Letter Queue (DLQ) is a holding area for jobs that failed permanently, for example after exhausting all retry attempts. Unless `removeOnFail` is set, each entry keeps the original job, its terminal error, and a complete ordered array of `AttemptRecord` values. The history includes retryable failures before the terminal attempt and remains attached across automatic DLQ redeliveries. Entries are not permanent by definition: `maxAge`, `maxEntries`, an explicit purge, or `Queue.obliterate()` can remove them. The defaults retain entries for seven days and cap each queue at 10,000 entries. Capacity eviction removes the oldest entry and its terminal ownership/result/log data from the selected backend: in-memory state, SQLite, or PostgreSQL. Persistent-backend deletion is committed atomically with the eviction. ## Quick Start ```typescript import { Queue } from 'bunqueue/client'; const queue = new Queue('emails', { embedded: true }); // See what failed and why const entries = queue.getDlq(); for (const entry of entries) { console.log(entry.job.id, entry.reason, entry.error); } // Put everything back in the queue for another try queue.retryDlq(); ``` ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('emails'); // List the dead jobs (over TCP they arrive as plain jobs, // without DLQ metadata like the failure reason) const jobs = await queue.getDlq(); for (const job of jobs) { console.log(job.id); } // Put everything back in the queue for another try await queue.retryDlq(); ``` ```python from bunqueue import Queue queue = Queue("emails") # List the dead jobs (over TCP they arrive as plain jobs, # without DLQ metadata like the failure reason) for job in queue.get_dlq(): print(job["id"]) # Put everything back in the queue for another try queue.retry_dlq() ``` ```php use Bunqueue\Queue; $queue = new Queue('emails'); // List the dead jobs (over TCP they arrive as plain jobs, // without DLQ metadata like the failure reason) foreach ($queue->getDlq() as $job) { echo $job['id'], PHP_EOL; } // Put everything back in the queue for another try $queue->retryDlq(); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{}) defer queue.Close() // List the dead jobs (over TCP they arrive as plain jobs, // without DLQ metadata like the failure reason) jobs, err := queue.GetDlq(0) for _, job := range jobs { fmt.Println(job["id"]) } // Put everything back in the queue for another try _, err = queue.RetryDlq("", 0) ``` ```rust use bunqueue_client::{ConnectionOptions, Queue}; let queue = Queue::new("emails", ConnectionOptions::default()); // List the dead jobs (over TCP they arrive as plain jobs, // without DLQ metadata like the failure reason) let jobs = queue.get_dlq(None)?; println!("{} dead jobs", jobs.len()); for job in &jobs { println!("{job:?}"); } // Put everything back in the queue for another try queue.retry_dlq(None, None)?; ``` ```elixir queue = Bunqueue.queue("emails") # List the dead jobs (over TCP they arrive as plain jobs, # without DLQ metadata like the failure reason) {:ok, jobs} = Bunqueue.Queue.dlq(queue) for job <- jobs, do: IO.puts(job["id"]) # Put everything back in the queue for another try {:ok, _count} = Bunqueue.Queue.retry_dlq(queue) ``` From the CLI, against a running server: ```bash bunqueue dlq list emails bunqueue dlq retry emails bunqueue dlq purge emails ``` :::note[Embedded vs TCP] The synchronous query API on this page (`getDlq()` and `getDlqStats()`) reads in-process state and therefore remains embedded-only. Synchronous TCP mutations, including `retryDlqByFilter()`, are fire-and-forget and return `0`; the filtered retry still reaches the broker. The `Async` variants work in both modes: `getDlqAsync(filter?)` returns full metadata and operational Job objects, `getDlqStatsAsync()` returns authoritative statistics, `retryDlqByFilterAsync(filter)` applies server-side filtering and returns the applied count, and the existing retry/purge/config async forms wait for the real result. `getDlqJobsAsync(count?)` remains the compact jobs-only view. ::: ## Where to go next | | | |---|---| | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | The same operations from an existing Queue instance | | [DLQ Operations](/guide/dlq/operations/) | Filter, retry selectively, check health, purge | | [Automatic DLQ Retry with Backoff](/guide/dlq/auto-retry/) | Let bunqueue re-queue dead entries on a backoff | | [DLQ Configuration](/guide/dlq/configuration/) | autoRetry, maxAge, maxEntries and the defaults | | [DLQ Reference](/guide/dlq/reference/) | Failure reasons, entry shape, every DLQ method | --- # SDKs: TypeScript, Python, PHP, Go, Rust, Elixir Six production-grade official SDKs for Node.js, Deno, Bun, Python, PHP, Go, Rust, Elixir and Cloudflare Workers. URL: https://bunqueue.dev/guide/sdks/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · sdks

Client SDKs for every runtime.

Official client SDKs for Node.js, Deno, Bun, Python, PHP, Go, Rust, Elixir and Cloudflare Workers. Each speaks the native TCP protocol and MessagePack through an idiomatic Queue and Worker API.

6 official SDKs, one queue 1 formal, versioned wire protocol produce in one language, consume in another retries, priorities, cron, DLQ: all server-side
The design is simple: the **server** owns every queue semantic, retries with backoff, priorities, scheduling, stall detection, the dead letter queue. Your **applications** only add jobs and process them. A Next.js API written in TypeScript can enqueue work that a Python service consumes, both against the same queue, with no shared runtime and no translation layer. ## Supported platforms | Platform | Package | Distribution | | ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Node.js ≥ 20, Bun, Deno ≥ 2, Cloudflare Workers | `bunqueue-client` | [npm](https://www.npmjs.com/package/bunqueue-client) · [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/typescript) | | Python ≥ 3.9 | `bunqueue-client` | [PyPI](https://pypi.org/project/bunqueue-client/) · [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/python) | | PHP ≥ 8.1 | `bunqueue/client` | [Packagist](https://packagist.org/packages/bunqueue/client) · [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) | | Go ≥ 1.26.5 | `github.com/egeominotti/bunqueue/sdk/go` | `go get`, [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/go) | | Rust ≥ 1.85 | `bunqueue-client` | `cargo add bunqueue-client` · [API docs](https://docs.rs/bunqueue-client/latest/bunqueue_client/) · [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) | | Elixir ≥ 1.15 | `bunqueue_client` | Hex upcoming, [source](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) | | Bun, embedded in process, no server | `bunqueue` | [Quick Start](/guide/quickstart/) | Every SDK is versioned independently from the server and follows semantic versioning; each changelog is linked in [Resources](#resources). The wire protocol is a formal, public, versioned contract (`protocolVersion: 3`, negotiated via `Hello` with the `separate-job-name` capability), so a client written today keeps working across compatible server upgrades, and services in different languages interoperate on the same queues out of the box. ## Getting started ### 1. Run the server The server is the only component that requires [Bun](https://bun.sh), and only if you run it with `bunx`. Docker and the prebuilt binary need nothing at all. ```bash bunx bunqueue start ``` One command, no install. Without `--data-path` (or `BUNQUEUE_DATA_PATH`) the queue is in-memory: pass it, e.g. `--data-path ./data/bunq.db`, to persist jobs to SQLite. ```bash docker run -d --name bunqueue \ -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ ghcr.io/egeominotti/bunqueue:latest ``` The named volume keeps the SQLite file across container restarts and upgrades. ```yaml # compose.yaml services: bunqueue: image: ghcr.io/egeominotti/bunqueue:latest ports: - '6789:6789' # TCP protocol (SDKs) - '6790:6790' # HTTP API (/health, /metrics) volumes: - bunqueue-data:/app/data # environment: # AUTH_TOKENS: "your-secret-token" volumes: bunqueue-data: ``` ```bash docker compose up -d ``` ```bash BUNQUEUE_STORAGE_DRIVER=postgres \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@localhost:5432/bunqueue' \ bunx bunqueue start ``` PostgreSQL 15–18 are supported and tested; 18.6 is recommended. Storage is configured only on the bunqueue server. Every SDK still connects to port 6789 and does not need a PostgreSQL driver, URL, or database credentials. Multiple server instances may share that one database and namespace. ```bash # download the binary for your platform from GitHub Releases curl -LO https://github.com/egeominotti/bunqueue/releases/latest/download/bunqueue-darwin-arm64 chmod +x bunqueue-darwin-arm64 ./bunqueue-darwin-arm64 start ``` Prebuilt binaries for macOS and Linux are attached to every [release](https://github.com/egeominotti/bunqueue/releases); no runtime required. Port 6789 serves the TCP protocol used by the SDKs, port 6790 serves the HTTP API with `/health` and `/metrics`. Additional deployment options are covered in [Running the Server](/guide/server/). ### 2. Install the client ```bash npm install bunqueue-client ``` ```bash bun add bunqueue-client ``` ```bash deno add npm:bunqueue-client ``` ```bash pip install bunqueue-client ``` ```bash composer require bunqueue/client ``` ```bash go get github.com/egeominotti/bunqueue/sdk/go ``` ```bash cargo add bunqueue-client ``` ```elixir # Hex release upcoming; use sdk/elixir as a path dependency today {:bunqueue_client, path: "../bunqueue/sdk/elixir"} ``` ### 3. Produce jobs ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('emails', { embedded: false }); await queue.add('welcome', { to: 'user@example.com' }); ``` ```python from bunqueue import Queue queue = Queue("emails") queue.add("welcome", {"to": "user@example.com"}) ``` ```php use Bunqueue\Queue; $queue = new Queue('emails'); $queue->add('welcome', ['to' => 'user@example.com']); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{}) defer queue.Close() queue.Add("welcome", map[string]any{"to": "user@example.com"}, nil) ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value}; let queue = Queue::new("emails", ConnectionOptions::default()); let data = Value::Map(vec![(Value::from("to"), Value::from("user@example.com"))]); queue.add("welcome", data, JobOptions::default())?; ``` ```elixir queue = Bunqueue.queue("emails") {:ok, _job} = Bunqueue.Queue.add(queue, "welcome", %{to: "user@example.com"}) ``` ### 4. Process jobs ```typescript import { Worker } from 'bunqueue-client'; const worker = new Worker('emails', async (job) => { await sendEmail(job.data.to); return { sent: true }; }, { embedded: false }); worker.on('completed', (job) => console.log('done:', job.id)); worker.on('error', (err) => console.error(err)); // always attach (see Worker semantics) ``` ```python from bunqueue import Worker def process(job): send_email(job.data["to"]) return {"sent": True} Worker("emails", process, concurrency=10).run() ``` ```php use Bunqueue\Worker; $worker = new Worker('emails', function (Bunqueue\Job $job) { sendEmail($job->data()['to']); return ['sent' => true]; }); $worker->installSignalHandlers(); $worker->run(); // blocking loop; or $worker->runOnce() from a cron tick ``` ```go worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { return sendEmail(job.Data()["to"].(string)) }, bunqueue.WorkerOptions{Concurrency: 8}) worker.Run() ``` ```rust use bunqueue_client::{ProcessError, Value, Worker, WorkerOptions}; let worker = Worker::new( "emails", |job| { deliver(job.data()) .map(|_| Value::from(true)) .map_err(|error| ProcessError::retryable(error.to_string())) }, WorkerOptions::default(), ); worker.run()?; ``` ```elixir worker = Bunqueue.Worker.new("emails", fn job -> send_email(job.data) {:ok, %{sent: true}} end, concurrency: 8) Bunqueue.Worker.run(worker) ``` Run either file with the runtime you already use: ```bash node --experimental-strip-types app.ts # Node 22 or later bun app.ts # Bun deno run -A app.ts # Deno 2 or later python app.py # Python php worker.php # PHP go run . # Go cargo run # Rust mix run app.exs # Elixir ``` Producer and worker are usually separate services, often in different languages: a Next.js API adds jobs, a Python service processes them, against the same queue and the same protocol. Constructors default to `host: 'localhost'` and `port: 6789`, so no options are needed for a local setup. ## Protocol and architecture Understanding four facts about the transport explains most SDK behavior: 1. **Framing**: every message is a 4-byte big-endian length prefix followed by a standard msgpack map. Maximum frame size is 64 MB; maximum job payload is 10 MB. 2. **Pipelining**: every request carries a `reqId` the server echoes back, so many commands are in flight on one socket concurrently. A single connection is usually all a service needs. 3. **Authentication-first**: when a token is configured, `Auth` is guaranteed to be the first frame on every (re)connection, in every SDK: TypeScript through synchronous write ordering, Python through a connection lock (safe under free-threaded concurrency), and PHP, Go, Rust, and Elixir inside the connect sequence itself. 4. **Job names and payloads stay separate**: protocol v3 encodes `add('welcome', {to})` as `name: 'welcome', data: {to}`. Scalars, arrays and `null` remain unchanged in `data`, and a user-owned `data.name` is preserved. Workers receive the separate `job.name` and original `job.data` values. ### Connection options ```typescript const queue = new Queue('emails', { embedded: false, connection: { host: 'queue.example.com', port: 6789, token: process.env.BUNQUEUE_TOKEN, tls: true, // or { caFile } or { rejectUnauthorized: false } commandTimeout: 30_000, // default maxInFlight: 100, // maximum in-flight commands per connection (default) poolSize: 4, // connection pool size (default) }, }); ``` ```python queue = Queue( "emails", host="queue.example.com", port=6789, token=os.environ["BUNQUEUE_TOKEN"], tls=True, # or {"ca_file": "./ca.pem"} or an ssl.SSLContext command_timeout=10.0, # default ) ``` ```php $queue = new Queue('emails', [ 'host' => 'queue.example.com', 'port' => 6789, 'token' => getenv('BUNQUEUE_TOKEN'), 'tls' => true, // or ['caFile' => './ca.pem'] or ['verifyPeer' => false] 'connectTimeout' => 10.0, // seconds, default 'commandTimeout' => 30.0, // seconds, default ]); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{ Host: "queue.example.com", Port: 6789, Token: os.Getenv("BUNQUEUE_TOKEN"), TLS: &bunqueue.TLSOptions{CAFile: "./ca.pem"}, // or &TLSOptions{} for system CAs ConnectTimeout: 10 * time.Second, // default CommandTimeout: 30 * time.Second, // default }) ``` ```rust use std::{path::PathBuf, time::Duration}; use bunqueue_client::{ConnectionOptions, Queue, TlsOptions}; let queue = Queue::new("emails", ConnectionOptions { host: "queue.example.com".into(), port: 6789, token: std::env::var("BUNQUEUE_TOKEN").ok(), tls: Some(TlsOptions { ca_file: Some(PathBuf::from("./ca.pem")) }), connect_timeout: Duration::from_secs(10), command_timeout: Duration::from_secs(30), ..Default::default() }); ``` ```elixir queue = Bunqueue.queue("emails", host: "queue.example.com", port: 6789, token: System.fetch_env!("BUNQUEUE_TOKEN"), tls: true, ca_file: "./ca.pem", timeout: 30_000 ) ``` The command timeout governs how long each in-flight command waits for a response. TypeScript and Python keep the TCP connect timeout internal; PHP, Go, and Rust expose it separately. Elixir applies its timeout to connect and command exchange. ### Connection resilience Every SDK preserves the same recovery invariants; implementation details follow the runtime: | Mechanism | Availability and behavior | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Lazy reconnect | All six: a lost connection reconnects on the next call; workers re-register after every connection generation | | Worker retry backoff | All six retry a failed pull loop without spinning; TypeScript, Python, PHP, Go, and Rust use bounded backoff, while Elixir uses a short fixed delay | | Producer fast-fail window | Legacy TypeScript and Python throttle repeated failed connection attempts (500 ms → 5 s); canonical TypeScript uses the shared client's reconnect policy | | TCP keepalive | TypeScript and Python enable an approximately 15 s idle probe where the OS exposes the controls | | Timeout-driven teardown | All six discard a stream whose framing state is ambiguous; TypeScript/Python tolerate a configurable consecutive-timeout threshold, PHP/Go/Rust/Elixir tear down immediately | | Backpressure | TypeScript optionally parks callers at `maxInFlight`; the synchronous clients naturally serialize/bound calls | | Auth ordering | All six prevent any command racing ahead of `Auth` after a reconnect | ## Producing jobs ### Job options Every transmitted option is validated server-side. The typed mappers preserve the common option set, with one audited exception: Elixir's `deduplication: %{id: ...}` maps the nested settings but does not derive the owning `uniqueKey`, so supply `uniqueKey` explicitly for now. Naming follows each language's idiom: TypeScript and PHP use camelCase keys (`attempts`, `jobId`, `removeOnComplete`), Python uses snake_case (`attempts`, `job_id`, `remove_on_complete`), Go takes a `bunqueue.JobOptions` map with the camelCase keys, Rust uses typed `JobOptions` fields, and Elixir accepts keyword or map options. Prefer typed builders: runtime handling of unknown keys differs by language. | Option | Type | Default | Notes | | -------------------------------------------- | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------- | | `priority` | number | 0 | Higher runs sooner; −1 000 000 … 1 000 000 | | `delay` | ms | 0 | Up to 1 year | | `attempts` | number | 3 | Max attempts including the first; up to 1000 | | `backoff` | number \| `{ type, delay }` | 1000 | `type`: `'fixed'` or `'exponential'`; delay up to 1 day | | `ttl` | ms | - | Expires the job if not processed in time | | `timeout` | ms | - | Per-job processing timeout, up to 1 day | | `jobId` | string | - | Custom id; **idempotent**, re-adding an unfinished id is a no-op | | `deduplication` | `{ id, ttl?, extend?, replace? }` | - | Dedup window keyed on `id` | | `debounce` | `{ id, ttl? }` | - | Persists compatibility metadata; use `deduplication` with `replace: true` for last-write-wins execution | | `dependsOn` | string[] | - | Job ids that must complete first | | `parentId` / `childrenIds` | string / string[] | - | Flow relationships (usually set via `FlowProducer`) | | `tags` / `groupId` | string[] / string | - | Metadata; `groupId` also scopes group rate limits | | `lifo` | boolean | false | At equal priority, LIFO jobs form a newest-first partition ahead of FIFO jobs | | `removeOnComplete` / `removeOnFail` | boolean | false | Drop the job record at the terminal state | | `durable` | boolean | false | SQLite: bypass its write buffer before ACK; PostgreSQL admission is already transactional | | `repeat` | `{ every }` or `{ pattern, tz? }` + `limit?` | - | Repeatable jobs (see [Cron](/guide/cron/)) | | `stallTimeout` | ms | - | Per-job stall detection override | | `stackTraceLimit` / `keepLogs` / `sizeLimit` | number | - | Failure stack cap, retained log lines, payload cap | ```typescript await queue.add('report', data, { priority: 10, delay: 5000, attempts: 5 }); await queue.add('charge', payment, { jobId: `order-${orderId}`, durable: true }); ``` ```python queue.add("report", data, priority=10, delay=5000, attempts=5) queue.add("charge", payment, job_id=f"order-{order_id}", durable=True) ``` ```php $queue->add('report', $data, ['priority' => 10, 'delay' => 5000, 'attempts' => 5]); $queue->add('charge', $payment, ['jobId' => "order-{$orderId}", 'durable' => true]); ``` ```go queue.Add("report", data, bunqueue.JobOptions{"priority": 10, "delay": 5000, "attempts": 5}) queue.Add("charge", payment, bunqueue.JobOptions{"jobId": "order-" + orderID, "durable": true}) ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ### Idempotency and bulk `jobId` makes `add` idempotent: re-adding an id whose job is still unfinished (waiting, active, waiting-children) returns the existing job instead of creating a duplicate. This holds for `addBulk` too, each bulk entry's `jobId` is preserved on the wire, so an idempotent batch ingest can be re-run safely after a crash: ```typescript await queue.addBulk( orders.map((o) => ({ name: 'ingest', data: o, opts: { jobId: `order-${o.id}` } })) ); ``` ```python queue.add_bulk([ {"name": "ingest", "data": o, "job_id": f"order-{o['id']}"} for o in orders ]) ``` ```php $queue->addBulk(array_map( fn ($o) => ['name' => 'ingest', 'data' => $o, 'jobId' => "order-{$o['id']}"], $orders )); ``` ```go entries := make([]bunqueue.BulkEntry, 0, len(orders)) for _, o := range orders { entries = append(entries, bunqueue.BulkEntry{ Name: "ingest", Data: o, Opts: bunqueue.JobOptions{"jobId": "order-" + o.ID}, }) } ids, err := queue.AddBulk(entries) ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ### Producer throughput For high-volume producers the TypeScript SDK can fan commands across a connection pool using the canonical nested connection options: ```typescript const queue = new Queue('ingest', { embedded: false, connection: { poolSize: 4 }, }); ``` Both canonical Queue and Worker use the shared transport implementation. Workers preserve per-job lease tokens and re-register after reconnect. In every SDK, `addBulk` is the first tool for producer throughput: one round-trip for the whole batch. The canonical TypeScript Queue also batches concurrent `add()` calls by default; configure it with `autoBatch`. ## Processing jobs ### Worker options The canonical TypeScript Worker uses the same options as `bunqueue/client`: `concurrency` defaults to 1, `pollTimeout` and `heartbeatInterval` use milliseconds, `lockDuration` controls the lease, and `limiter`, `group`, and processor `batch` are supported. See the [shared Worker reference](/guide/worker/options/). The table below describes the other SDKs and the historical TypeScript `bunqueue-client/legacy` API; its `pollTimeoutMs`, `lockTtlMs`, `heartbeatIntervalS`, and opt-in `ackBatch` names do not apply to the default export. | Option | Default | Notes | | -------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------- | | `concurrency` | 4 in legacy TypeScript/Python/Go/Rust; 1 in Elixir/PHP | Jobs processed in parallel except PHP, which is sequential by design | | `batchSize` | usually 10; Elixir defaults to concurrency | Jobs fetched per `PULLB`, capped by free slots and the server max (1000) | | `pollTimeoutMs` | usually 5000; Elixir 1000 | Server-side long-poll; max 30 000 | | `lockTtlMs` | 30 000 | Job lease TTL | | `heartbeatIntervalS` | 10; Go defaults to disabled | Worker + per-job lock heartbeats; 0 disables (Go: negative or `DisableHeartbeat: true`) | | `ackBatch` | off | Opt-in ACK batching (below; legacy TypeScript and Python) | | `autorun` | true where exposed | Legacy TypeScript/Python can start at construction; PHP, Go, Rust, and Elixir start explicitly | Names follow each language: Python, Rust, and Elixir use snake_case; PHP uses camelCase array keys; Go uses a `WorkerOptions` struct (`PollTimeoutMs`, `LockTtlMs`, ...). ### Lease model A pulled job carries a **lock token**. The worker heartbeats every active job's lock on the heartbeat interval, so a job that legitimately runs longer than the lock TTL survives. If the worker dies, the lease expires and the server requeues the job (or moves it to the DLQ once `maxStalls` is exceeded), at-least-once delivery, so make handlers idempotent. PHP is the deliberate sequential exception: it can heartbeat between jobs, but it cannot interrupt a running user callback. A PHP handler that can exceed `lockTtlMs` must call `$job->extendLock(...)` from the callback or split the work into shorter jobs. ### Failures ```typescript import { UnrecoverableError } from 'bunqueue-client'; const worker = new Worker('emails', async (job) => { if (!isValid(job.data)) { throw new UnrecoverableError('malformed payload'); // skip retries → DLQ } return await send(job.data); }, { embedded: false }); ``` ```python from bunqueue import UnrecoverableError, Worker def process(job): if not is_valid(job.data): raise UnrecoverableError("malformed payload") # skip retries -> DLQ return send(job.data) worker = Worker("emails", process) ``` ```php use Bunqueue\UnrecoverableError; use Bunqueue\Worker; $worker = new Worker('emails', function (Bunqueue\Job $job) { if (!isValid($job->data())) { throw new UnrecoverableError('malformed payload'); // skip retries -> DLQ } return send($job->data()); }); ``` ```go worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { if !isValid(job.Data()) { return nil, bunqueue.NewUnrecoverableError("malformed payload") // skip retries -> DLQ } return send(job.Data()) }, bunqueue.WorkerOptions{}) ``` Panics are recovered, failed with their real stack, and never kill the worker. Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). A thrown error fails the job with its message and the leading stack lines (persisted server-side, capped by `stackTraceLimit`); the server applies the retry/backoff policy and eventually the [dead letter queue](/guide/dlq/). `UnrecoverableError` bypasses retries entirely. ### Worker events TypeScript, Python, PHP, and Go expose worker lifecycle listeners. Rust and Elixir use their structured telemetry callback for transport/command lifecycle and normal language control flow for per-job handler outcomes. ```typescript worker.on('completed', (job, result) => log.info('done', job.id)); worker.on('failed', (job, err) => log.warn('failed', job.id, err.message)); worker.on('error', (err) => log.error(err)); // always attach ``` ```python worker.on("completed", lambda job, result: log.info("done %s", job.id)) worker.on("failed", lambda job, err: log.warning("failed %s: %s", job.id, err)) worker.on("error", lambda err: log.error(err)) ``` ```php $worker->on('completed', fn ($job, $result) => $log->info("done {$job->id()}")); $worker->on('failed', fn ($job, $err) => $log->warning("failed {$job->id()}")); $worker->on('error', fn ($err) => $log->error($err->getMessage())); ``` ```go worker.On("completed", func(args ...any) { job := args[0].(*bunqueue.Job) log.Printf("done %s", job.ID()) }) worker.On("error", func(args ...any) { log.Println(args[0]) }) ``` Rust has no worker lifecycle listeners. Record per-job outcomes where the processor returns them, and pass the connection `telemetry` callback for transport and command lifecycle events. Elixir has no worker lifecycle listeners. Record per-job outcomes where the processor returns them, and pass the connection `:event_handler` callback for transport and command lifecycle events. **In TypeScript, always attach an `error` listener**: per Node `EventEmitter` semantics an unhandled `error` event throws. The other SDKs swallow listener exceptions. Every worker frees each job's concurrency slot _before_ emitting, so a throwing listener cannot leak a slot or degrade throughput in any SDK. The error itself is yours to observe. Terminal outcomes are broker-authoritative in every official SDK. If a job timeout or retired cron lease wins before a processor returns, the successful `already-finalized` ACK/FAIL response settles that handler attempt without emitting a contradictory `completed`/`failed` event or incrementing a terminal counter. Rust does not synthesize terminal events or counters; Elixir applies the same rule to its Worker counters. Malformed outcome evidence is surfaced as a protocol error instead of being treated as completion. ### ACK batching (high volume) Opt-in: coalesce completed-job acknowledgements into `ACKB` round-trips. Available as an explicit option in legacy TypeScript and Python; the PHP, Go, Rust and Elixir workers acknowledge each job individually. The canonical TypeScript Worker manages acknowledgement batching internally and does not expose the legacy `ackBatch` option. The opt-in `ackBatch` option belongs to the historical API. Import it explicitly: ```typescript import { Worker } from 'bunqueue-client/legacy'; const worker = new Worker('ingest', process, { concurrency: 32, ackBatch: { enabled: true, maxSize: 50, maxDelayMs: 5 }, }); ``` ```python worker = Worker( "ingest", process, concurrency=32, ack_batch={"max_size": 50, "max_delay_ms": 5}, ) ``` ACK batching is a TypeScript and Python feature. The PHP worker acknowledges each job individually; there is no batching option to configure. ACK batching is a TypeScript and Python feature. The Go worker acknowledges each job individually; there is no batching option to configure. ACK batching is a TypeScript and Python feature. The Rust worker acknowledges each job individually; there is no batching option to configure. ACK batching is a TypeScript and Python feature. The Elixir worker acknowledges each job individually; there is no batching option to configure. Semantics are strict: a job stays _active_, its lock still heartbeated, until the server confirms the batch; the batch is flushed on `close()`; every job settles exactly once even if an event listener throws. Defaults are off, so nothing changes unless you enable it. When the broker ignores a retired generation, TypeScript and Python require exact positional `ignoredIndices`; they never infer a position from `ignoredIds`, because one batch can contain two lease generations with the same job ID. ### Graceful shutdown ```typescript await worker.close(); // stop pulling, flush batched ACKs, drain in-flight jobs await worker.close(true); // force: skip the in-flight drain ``` ```python worker.close() # stop pulling, wait for in-flight jobs to drain worker.close(timeout=5) # bound the wait; the drain continues in the background ``` Python has no force flag: `close(timeout=...)` bounds how long the call waits, and `close(timeout=0)` detaches immediately while in-flight jobs finish in the background. ```php $worker->installSignalHandlers(); // SIGTERM / SIGINT -> graceful stop $worker->stop(); // finish the in-flight job, then return from run() $worker->close(); // unregister and close the connection ``` The PHP worker is sequential, so stopping waits for at most one in-flight job; the unprocessed rest of the batch is re-leased by the server. ```go worker.Stop() // stop pulling; in-flight jobs finish worker.Close() // unregister and close the connection ``` Not shown here. The Rust equivalent is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). Not shown here. The Elixir equivalent is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). ## Query, control and operations `bunqueue-client` builds its default exports from the same source as `bunqueue/client`: the TypeScript APIs, options, job objects, and TCP behavior are identical. Build-time export/signature checks and real-broker tests across Bun, Node.js, Deno, and Cloudflare Workers prevent a separately maintained API from drifting. Embedded storage requires Bun, and a local `SandboxedWorker` thread pool requires a runtime with worker threads. The other SDKs implement their language-specific producer/worker baseline, queries, control, basic DLQ operations, schedulers, flows, auth, and TLS. Their coverage differs from the shared TypeScript surface: | Area | TypeScript | Python | PHP | Go | Rust | Elixir | | --------------------------------------- | ----------------- | ----------------- | ------------------------ | ----------------- | ----------------- | -------------- | | Basic Queue + Worker | Full | Full | Full (sequential worker) | Full | Full | Full | | Full 32-operation Job API | Full | 13 | 4 | 4 | 3 | 2 | | Dependency/waiting-children operations | Full | Partial | Partial | Partial | Missing | Missing | | Limit mutation / readback | Full / full | Partial / missing | Partial / missing | Partial / missing | Partial / missing | Full / missing | | Rich DLQ + selector-aware bulk retry | Full | Partial | Missing | Missing | Missing | Missing | | Flow bulk + fan-in + readback | Full | Full | Read only | Read only | Missing | Missing | | Queue-scoped workers/schedulers | Full | Workers global | Both global | Both global | Missing/partial | Missing/global | | QueueGroup and forwarding | Full | Missing | Missing | Missing | Missing | Missing | | Simple Mode | Full | Full | Missing | Missing | Missing | Missing | Canonical TypeScript supports deduplication-key lookup/removal, rate-limit windows and introspection, `count`/`timestamp` bulk-retry selectors, and exhaustive `end: -1` pagination. Use the `Async` Queue variants for authoritative TCP reads and mutations. The internal [SDK parity audit](https://github.com/egeominotti/bunqueue/blob/main/docs/features/polyglot-sdks.md) tracks remaining differences in the other language SDKs and distinguishes API coverage from runtime capabilities. The historical TypeScript API remains available through the explicit `bunqueue-client/legacy` export; its constructor and result shapes are separate from the canonical default API. TypeScript and PHP use camelCase, Python/Rust/Elixir use snake_case, and Go uses exported Go style (`GetJobCounts`, `RetryDlq`, ...). A representative shared baseline: ```typescript const job = await queue.getJob(id); // null when missing const state = await queue.getJobState(id); const result = await job?.waitUntilFinished(null, 30_000); const counts = await queue.getJobCountsAsync(); await queue.pauseAsync(); const dropped = await queue.drainAsync(); const retried = await queue.retryDlqAsync(); ``` ```python job = queue.get_job(id) # None when missing state = queue.get_state(id) # "waiting" | "active" | ... result = queue.wait_for_job(id, timeout_ms=30000) counts = queue.get_job_counts() # {"waiting": ..., "active": ..., ...} queue.pause() dropped = queue.drain() # number of removed jobs queue.retry_dlq() # re-queue dead-lettered jobs ``` ```php $job = $queue->getJob($id); // null when missing $state = $queue->getState($id); // 'waiting' | 'active' | ... $result = $queue->waitForJob($id, 30000); $counts = $queue->getJobCounts(); // ['waiting' => ..., 'active' => ..., ...] $queue->pause(); $dropped = $queue->drain(); // number of removed jobs $queue->retryDlq(); // re-queue dead-lettered jobs ``` ```go job, _ := queue.GetJob(id) // nil when missing state, _ := queue.GetState(id) // "waiting" | "active" | ... result, _ := queue.WaitForJob(id, 30000) counts, _ := queue.GetJobCounts() // map[string]int{"waiting": ..., ...} _ = queue.Pause() dropped, _ := queue.Drain() // number of removed jobs _, _ = queue.RetryDlq("", 0) // re-queue dead-lettered jobs ``` ```rust let job = queue.get_job(&id)?; // None when missing let state = queue.get_state(&id)?; let result = queue.wait_for_job(&id, 30_000)?; let counts = queue.get_job_counts()?; queue.pause()?; let dropped = queue.drain()?; queue.retry_dlq(None, None)?; ``` ```elixir {:ok, job} = Bunqueue.Queue.get_job(queue, id) # nil when missing {:ok, state} = Bunqueue.Queue.get_state(queue, id) {:ok, result} = Bunqueue.Queue.wait_for_job(queue, id, 30_000) {:ok, counts} = Bunqueue.Queue.get_job_counts(queue) :ok = Bunqueue.Queue.pause(queue) {:ok, dropped} = Bunqueue.Queue.drain(queue) {:ok, _count} = Bunqueue.Queue.retry_dlq(queue) ``` Two behaviors worth knowing: - **Not-found is `null`/`None`/`nil`**, never an exception, `getJob`, `getJobByCustomId` and `getJobScheduler` map the server's not-found response for you. - **Progress updates require an active job**; the server rejects progress updates on waiting jobs by design. ## Flows Pipelines and parent/child trees with automatic ordering, children complete before their parent, results are readable from the parent: ```typescript import { FlowProducer } from 'bunqueue-client'; const flow = new FlowProducer({ embedded: false }); // Sequential pipeline await flow.addChain([ { name: 'extract', queueName: 'etl', data: {} }, { name: 'transform', queueName: 'etl', data: {} }, { name: 'load', queueName: 'etl', data: {} }, ]); // Tree: parent waits for its children const node = await flow.add({ name: 'assemble', queueName: 'orders', data: {}, children: [ { name: 'reserve-stock', queueName: 'orders', data: {} }, { name: 'charge-card', queueName: 'orders', data: {} }, ], }); // Fan-in: N parallel jobs converge into one await flow.addBulkThen(parts, { name: 'merge', queueName: 'orders', data: {} }); ``` ```python from bunqueue import FlowProducer flow = FlowProducer() # Sequential pipeline flow.add_chain([ {"name": "extract", "queueName": "etl"}, {"name": "transform", "queueName": "etl"}, {"name": "load", "queueName": "etl"}, ]) # Tree: parent waits for its children node = flow.add({ "name": "assemble", "queueName": "orders", "children": [ {"name": "reserve-stock", "queueName": "orders"}, {"name": "charge-card", "queueName": "orders"}, ], }) # Fan-in: N parallel jobs converge into one flow.add_bulk_then(parts, {"name": "merge", "queueName": "orders"}) ``` ```php use Bunqueue\FlowProducer; $flow = new FlowProducer(); // Sequential pipeline $flow->addChain([ ['name' => 'extract', 'queueName' => 'etl'], ['name' => 'transform', 'queueName' => 'etl'], ['name' => 'load', 'queueName' => 'etl'], ]); // Tree: parent waits for its children $node = $flow->add([ 'name' => 'assemble', 'queueName' => 'orders', 'children' => [ ['name' => 'reserve-stock', 'queueName' => 'orders'], ['name' => 'charge-card', 'queueName' => 'orders'], ], ]); ``` ```go flow := bunqueue.NewFlowProducer(bunqueue.Options{}) defer flow.Close() // Sequential pipeline ids, err := flow.AddChain([]bunqueue.ChainStep{ {Name: "extract", QueueName: "etl"}, {Name: "transform", QueueName: "etl"}, {Name: "load", QueueName: "etl"}, }) // Tree: parent waits for its children node, err := flow.Add(bunqueue.FlowJob{ Name: "assemble", QueueName: "orders", Children: []bunqueue.FlowJob{ {Name: "reserve-stock", QueueName: "orders"}, {Name: "charge-card", QueueName: "orders"}, }, }) ``` ```rust use bunqueue_client::{ ChainStep, ConnectionOptions, FlowProducer, JobOptions, Value, }; let flow = FlowProducer::new(ConnectionOptions::default()); let step = |name: &str| ChainStep { name: name.into(), queue_name: "etl".into(), data: Value::Nil, options: JobOptions::default(), }; let ids = flow.add_chain(vec![ step("extract"), step("transform"), step("load"), ])?; ``` ```elixir flow = Bunqueue.FlowProducer.new() {:ok, ids} = Bunqueue.FlowProducer.add_chain(flow, [ %{name: "extract", queue: "etl"}, %{name: "transform", queue: "etl"}, %{name: "load", queue: "etl"} ]) ``` Fan-in (`addBulkThen`, N parallel jobs converging into one final job) is available in TypeScript and Python. Every current external SDK plans the complete graph before I/O and sends one broker-side `PUSHF` command, matching the Bun package's all-or-nothing creation and visibility guarantee. Previously published versions that compose `PUSH`/`UpdateParent` remain server-compatible, but that historical multi-request sequence is not atomic. Where exposed (Python, PHP, and Go), external-SDK `getFlow(id)` returns `null` for a missing root and skips children removed since creation, yielding the surviving partial tree. Rust and Elixir currently create atomic trees/chains but expose no typed flow reader. Both TypeScript packages fails on a missing descendant or malformed/cross-linked topology so corruption cannot masquerade as a valid partial graph. See the [Flow guide](/guide/flow/) for the exact per-client contract. ## Observability All six SDKs expose opt-in, dependency-free structured telemetry. Bring OpenTelemetry, Prometheus, `Logger`, `tracing`, or your own collector; SDKs stay silent by default. Consumer callback exceptions and Rust callback panics are isolated so an observer can never break transport or worker correctness. These telemetry hooks belong to the historical SDK API, available from the explicit `/legacy` export. The canonical client exposes the Bun client's Worker and QueueEvents events, connection health, and broker metrics. ```typescript import { Queue, consoleLogger, type TelemetryEvent } from 'bunqueue-client/legacy'; const queue = new Queue('emails', { logger: consoleLogger('info'), // or any { debug, info, warn, error } onTelemetry: (e: TelemetryEvent) => { if (e.type === 'command') histogram.observe({ cmd: e.cmd }, e.durationMs); if (e.type === 'reconnect_scheduled') reconnects.inc(); }, }); // Connection is an EventEmitter for imperative lifecycle hooks: queue.connection.on('connect', (i) => log.info('link up', i)); queue.connection.on('disconnect', (i) => log.warn('link down', i)); queue.connection.on('reconnect_scheduled', (i) => log.warn('retrying', i)); ``` ```python queue = Queue("emails", on_telemetry=lambda event: metrics.observe(event)) ``` ```php $queue = new Queue('emails', [ 'onEvent' => fn (array $event) => $metrics->observe($event), ]); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{ OnEvent: func(event bunqueue.TelemetryEvent) { metrics.Observe(event) }, }) ``` ```rust use std::sync::Arc; use bunqueue_client::{ConnectionOptions, Queue, TelemetryCallback}; let telemetry: TelemetryCallback = Arc::new(|event| tracing::debug!(?event)); let queue = Queue::new("emails", ConnectionOptions { telemetry: Some(telemetry), ..Default::default() }); ``` ```elixir queue = Bunqueue.queue("emails", event_handler: fn event -> Logger.info("bunqueue", bunqueue: event) end ) ``` The idiomatic event sets cover connection/reconnection, authentication, command latency and outcome, timeout, transport error, and close as applicable to each runtime; worker retry events are added where the client has a retry loop. Tokens, job payloads, results, private keys, and CA contents are never recorded. Scrape the server's Prometheus-text `/prometheus` endpoint for authoritative queue and broker metrics; `/metrics` is the JSON operational snapshot. ## Simple Mode `Bunqueue` bundles a Queue and a Worker into a single object, a 1:1 port of the official client's [Simple Mode](/guide/simple-mode/). It brings routes, onion middleware, in-process retry strategies, a circuit breaker, batch accumulation, event triggers, job TTL, priority aging, cooperative cancellation, and deduplication or debounce defaults. Available in TypeScript and Python; in PHP and Go, compose `Queue` and `Worker` directly. ```typescript import { Bunqueue } from 'bunqueue-client'; const app = new Bunqueue('notifications', { embedded: false, routes: { 'send-email': async (job) => ({ sent: true }), 'send-sms': async (job) => ({ sent: true }), }, concurrency: 10, retry: { maxAttempts: 5, strategy: 'jitter' }, circuitBreaker: { threshold: 5, resetTimeout: 30_000 }, }); app.use(async (job, next) => { console.time(job.name); const result = await next(); console.timeEnd(job.name); return result; }); await app.add('send-email', { to: 'alice@example.com' }); await app.cron('daily-digest', '0 9 * * *', { to: 'all' }); ``` ```python from bunqueue import Bunqueue app = Bunqueue( "notifications", routes={ "send-email": lambda job: {"sent": True}, "send-sms": lambda job: {"sent": True}, }, concurrency=10, retry={"max_attempts": 5, "strategy": "jitter"}, circuit_breaker={"threshold": 5, "reset_timeout": 30000}, ) def timing(job, next_fn): result = next_fn() print(f"{job.name} done") return result app.use(timing) app.add("send-email", {"to": "alice@example.com"}) app.cron("daily-digest", "0 9 * * *", {"to": "all"}) ``` Simple Mode ships in the TypeScript and Python SDKs only. In PHP, compose `Queue` and `Worker` directly and route on the job name inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Go, compose `Queue` and `Worker` directly and route on the job name inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Rust, compose `Queue` and `Worker` directly and route on the job name inside the processor. Simple Mode ships in the TypeScript and Python SDKs only. In Elixir, compose `Queue` and `Worker` directly and route on the job name inside the processor. Scheduler job options are honored end to end for each SDK's supported fields. TypeScript, Python, PHP, and Go expose the broadest scheduler template and repeat flags. Rust omits list plus some flags, while Elixir's scheduler list is server-wide and its deduplication shorthand has the `uniqueKey` caveat above. `embedded: true` requires the Bun runtime, including when importing `bunqueue-client`. On Node.js and Deno, set `embedded: false` and connect to the broker. The client reports a clear error if embedded mode is requested without Bun. ## Cloudflare Workers The same client runs inside Workers. Enable Node.js compatibility and add jobs directly from your fetch handlers: ```toml # wrangler.toml compatibility_flags = ["nodejs_compat"] compatibility_date = "2025-01-01" ``` ```typescript import { Queue } from 'bunqueue-client'; export default { async fetch(req: Request, env: Env): Promise { const queue = new Queue('signups', { embedded: false, connection: { host: env.BQ_HOST, port: 6789, token: env.BQ_TOKEN, tls: true, }, }); try { const job = await queue.add('welcome', await req.json()); return Response.json({ queued: job.id }); } finally { await queue.disconnect(); } }, }; ``` Workers are request-scoped, so there is no long-lived worker loop. Instead: produce from fetch handlers, and consume in batches from a [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/), pull, process, acknowledge, return. Both patterns work with Simple Mode too. Two requirements apply: the server must be reachable from the internet, and TLS needs a publicly trusted certificate. ## Security Authentication uses server-side tokens; transport security uses native TLS. ```typescript const queue = new Queue('emails', { embedded: false, connection: { host: 'queue.example.com', port: 6789, token: process.env.BUNQUEUE_TOKEN, // server started with AUTH_TOKENS=... tls: true, // or { caFile: './ca.pem' } for a custom CA }, }); ``` ```python queue = Queue( "emails", host="queue.example.com", port=6789, token=os.environ["BUNQUEUE_TOKEN"], tls={"ca_file": "./ca.pem"}, # or True for system CAs, or an ssl.SSLContext ) ``` ```php $queue = new Queue('emails', [ 'host' => 'queue.example.com', 'port' => 6789, 'token' => getenv('BUNQUEUE_TOKEN'), // server started with AUTH_TOKENS=... 'tls' => ['caFile' => './ca.pem'], // or true for system CAs ]); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{ Host: "queue.example.com", Port: 6789, Token: os.Getenv("BUNQUEUE_TOKEN"), // server started with AUTH_TOKENS=... TLS: &bunqueue.TLSOptions{CAFile: "./ca.pem"}, // or &TLSOptions{} for system CAs }) ``` ```rust use std::path::PathBuf; use bunqueue_client::{ConnectionOptions, Queue, TlsOptions}; let queue = Queue::new("emails", ConnectionOptions { host: "queue.example.com".into(), token: std::env::var("BUNQUEUE_TOKEN").ok(), tls: Some(TlsOptions { ca_file: Some(PathBuf::from("./ca.pem")) }), ..Default::default() }); ``` ```elixir queue = Bunqueue.queue("emails", host: "queue.example.com", token: System.fetch_env!("BUNQUEUE_TOKEN"), tls: true, ca_file: "./ca.pem" ) ``` Certificate verification is **on by default** for every TLS connection in every SDK, a wrong or missing CA rejects the connection instead of silently connecting; only an explicit opt-out (`rejectUnauthorized: false` in TypeScript, `{"verify": False}` in Python, `['verifyPeer' => false]` in PHP, `InsecureSkipVerify: true` in Go, `verify: false` in Elixir) switches to encryption-only mode for development. Rust deliberately exposes no insecure TLS mode. Start the server with `AUTH_TOKENS` to require authentication, and with `TLS_CERT_FILE` plus `TLS_KEY_FILE` for encrypted transport. Full hardening guidance lives in the [deployment guide](/guide/deployment/). ## Errors and guarantees Every SDK exposes typed equivalents of the same error categories, so retry logic can branch precisely (PHP uses exception classes under `Bunqueue\Exception`; Go uses `errors.As`; Rust uses the `Error` enum; Elixir returns `{:error, exception}` or raises through bang APIs): | Error | Meaning | | ------------------------ | ------------------------------------------------------------------------------------- | | Connection | Link lost or server unreachable (in-flight commands reject; the next call reconnects) | | Timeout | No response within the command timeout, including `waitForJob` timeout | | Command | The server answered `ok: false` (validation, failed wait, or a write-side not-found) | | Authentication | Token rejected during the connection handshake | | Protocol / serialization | Invalid map, frame, MessagePack extension, or oversized outgoing body | | Unrecoverable processing | Raised/returned _by your processor_ to skip retries and fail terminally | Delivery is **at-least-once**: a worker crash after processing but before the ACK means the job runs again, design handlers to be idempotent (the `jobId` option helps on the producing side). Two numeric-precision rules: - **JavaScript** numbers are IEEE 754 doubles, exact up to 2⁵³. Pass larger 64-bit identifiers (snowflake ids) as strings; never put `BigInt` in job data. - **Python, PHP, Go, Rust, and Elixir** integers outside the int32 range are recursively encoded as float64 on the wire (exact up to 2⁵³, safe for millisecond timestamps); the same string rule applies to larger identifiers. ## Write a client in any language The wire protocol is small and fully documented: length-prefixed msgpack frames, one command per message, plain request/response. If your language is not covered yet, you can build a client against the [wire protocol specification](https://github.com/egeominotti/bunqueue/blob/main/docs/protocol.md) and certify it with the [conformance suite](https://github.com/egeominotti/bunqueue/tree/main/sdk/conformance): point its runner at your client and it tells you exactly what to fix. The official SDKs are built and certified the same way. CI and the isolated release gate execute all 18 conformance checks for every official SDK against both SQLite and PostgreSQL 18.6. The matrix therefore proves the real producer, worker, retry/DLQ, scheduler, wait-for-result, pause/resume, bulk, Unicode, authentication, and atomic FlowProducer paths through each language driver; it does not infer PostgreSQL compatibility from the TypeScript client alone. Two environments are out of scope today. Browsers have no raw-TCP API, so route through your own backend instead of connecting directly. WebAssembly is feasible but not one portable runtime: a WASM target becomes official only when it can pass the same conformance suite without weakening authentication, TLS, or framing. ## Resources - [`bunqueue-client` on npm](https://www.npmjs.com/package/bunqueue-client), the full step by step README, from server start to production - Changelogs: [TypeScript](https://github.com/egeominotti/bunqueue/blob/main/sdk/typescript/CHANGELOG.md) · [Python](https://github.com/egeominotti/bunqueue/blob/main/sdk/python/CHANGELOG.md) · [PHP](https://github.com/egeominotti/bunqueue/blob/main/sdk/php/CHANGELOG.md) · [Go](https://github.com/egeominotti/bunqueue/blob/main/sdk/go/CHANGELOG.md) · [Rust](https://github.com/egeominotti/bunqueue/blob/main/sdk/rust/CHANGELOG.md) · [Elixir](https://github.com/egeominotti/bunqueue/blob/main/sdk/elixir/CHANGELOG.md) - [Queue API](/guide/queue/), every job option explained - [Worker](/guide/worker/), concurrency, events, graceful shutdown - [Simple Mode](/guide/simple-mode/), everything in one object - [Flows](/guide/flow/), pipelines, parent and child jobs, fan in - [Cron Jobs](/guide/cron/), schedules and repeatable jobs - [Dead Letter Queue](/guide/dlq/), what happens when jobs fail - [Deployment guide](/guide/deployment/), Docker, TLS, authentication, monitoring - [Wire protocol specification](https://github.com/egeominotti/bunqueue/blob/main/docs/protocol.md), the normative contract every SDK implements - [Conformance suite](https://github.com/egeominotti/bunqueue/tree/main/sdk/conformance), certify a client in any language - SDK sources: [`sdk/typescript`](https://github.com/egeominotti/bunqueue/tree/main/sdk/typescript) · [`sdk/python`](https://github.com/egeominotti/bunqueue/tree/main/sdk/python) · [`sdk/php`](https://github.com/egeominotti/bunqueue/tree/main/sdk/php) · [`sdk/go`](https://github.com/egeominotti/bunqueue/tree/main/sdk/go) · [`sdk/rust`](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust) · [`sdk/elixir`](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir) --- # SDK Performance: TS, Python, PHP, Go, Rust & Elixir Native benchmarks of every official bunqueue SDK: producer throughput and latency, worker throughput, memory, variance and bottleneck analysis. URL: https://bunqueue.dev/guide/sdk-benchmarks/ import { Aside } from '@astrojs/starlight/components'; This report compares all six official network SDKs against the same bunqueue broker. It is designed to answer two different questions: 1. How much producer throughput can each client sustain before the broker and SQLite become the limiting system? 2. How efficiently does each Worker refill its concurrency slots when handlers are short and I/O-bound? The results were measured on 20 July 2026 at repository revision `8f276e03ae480bc1abc615869769e48e087f7804` (`bunqueue` 2.8.43). They are a dated engineering result, not a universal capacity promise. ## Test environment | Component | Version | | --- | --- | | Host | Apple M1 Max, 10 cores, 32 GiB, arm64 | | Operating system | macOS 26.5.2 (25F84) | | Broker runtime | Bun 1.3.14 | | Python | CPython 3.14.3 | | PHP | PHP CLI 8.5.8, NTS | | Go | Go 1.26.5 | | Rust | rustc/cargo 1.97.0, release build | | Elixir | Elixir 1.20.2, Erlang/OTP 29 | Every measured sample used a fresh native Bun broker, SQLite database, dynamic TCP and HTTP ports, and unique queue. No benchmark number came from Docker or a VM. SDK order rotated between samples to reduce thermal and ordering bias. Each SDK/scenario combination received one unmeasured warm-up followed by five independent measured samples. External sampling recorded the client and broker process-tree RSS every 50 ms. Setup, preload, shutdown, and final verification were excluded from timed regions. Across measured samples the campaign submitted 600,000 producer jobs and processed 150,000 worker jobs. Every sample ended with the exact expected server-side job count. This is a conservation check, not a claim of exactly-once handler execution; bunqueue's delivery contract remains at-least-once. ## Producer workload Each sample submits 20,000 jobs with an approximately 256-byte payload through: - eight concurrent connections/producers; - `addBulk` batches of 50 jobs; - 400 measured bulk calls per sample; - local TCP and MessagePack, using each SDK's real production client. Throughput is total accepted jobs divided by elapsed wall-clock time. Call latency percentiles are calculated inside each sample; the table reports the median p50, p95, and p99 across its five samples. CV is the coefficient of variation of sample throughput: standard deviation divided by mean. ### Producer results | SDK | Median jobs/s | Mean jobs/s | Min–max jobs/s | CV | p50 | p95 | p99 | Peak client RSS | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | Rust release | **63,735** | 62,985 | 61,347–63,859 | 1.63% | **3.936 ms** | 15.734 ms | **18.800 ms** | **8.0 MiB** | | PHP | 62,053 | 61,737 | 60,464–62,675 | 1.22% | 4.365 ms | **15.274 ms** | 19.443 ms | 73.2 MiB | | Go | 61,665 | 61,472 | 60,664–61,966 | **0.73%** | 4.243 ms | 15.698 ms | 19.292 ms | 17.3 MiB | | TypeScript/Bun | 61,511 | 61,426 | 60,254–62,570 | 1.39% | 4.404 ms | 17.048 ms | 20.271 ms | 79.4 MiB | | Python | 60,701 | 60,993 | 60,325–61,960 | 1.07% | 4.252 ms | 16.508 ms | 19.604 ms | 29.2 MiB | | Elixir/OTP | 60,429 | 60,176 | 58,046–61,178 | 1.86% | 4.423 ms | 16.829 ms | 23.829 ms | 109.7 MiB | PHP RSS is the aggregate of eight producer processes, while the other rows are single-runtime process trees. RSS also includes each runtime's allocator, garbage collector, loaded standard library, and JIT where applicable; it is an operational footprint measurement, not bytes retained per job. ### Producer interpretation The fastest and slowest medians differ by only 5.5%. All six SDKs therefore reach the same broad broker-bound plateau. Rust leads by roughly 3.6% over TypeScript and has the lowest client RSS and p99, but changing producer language alone will not multiply queue capacity in this workload. The tail is also compact: five SDKs remain below 20.3 ms p99, while Elixir reaches 23.8 ms. Go has the lowest throughput variation. Every producer CV is below 2%, so the ranking is stable on this host, although the small gaps should not be treated as architectural differences. For production producers, batching is the important decision. A sequential `add()` pays one command round trip per job; this workload amortizes framing, MessagePack decoding, dispatch, and persistence over 50 jobs while eight connections keep the broker supplied. ## Worker workload Each sample starts with 5,000 preloaded jobs and drains them with: - a 1 ms simulated I/O handler; - total concurrency of 16; - batch pulls over real TCP connections; - an individual ACK for every completed job; - final polling until the server reports exactly 5,000 completed jobs. TypeScript, Python, Go, Rust, and Elixir use one SDK Worker configured for 16-way concurrency. PHP's Worker is sequential by design, so its idiomatic equivalent is 16 independent CLI worker processes. The timer measures the complete drain, including pull, handler, ACK, and worker refill time. ACK batching was disabled for comparability. TypeScript and Python support opt-in `ACKB`; enabling it could reduce acknowledgement round trips, but would give those two clients a protocol optimization unavailable in the other rows. ### Worker results | SDK | Median jobs/s | Mean jobs/s | Min–max jobs/s | CV | Peak client RSS | | --- | ---: | ---: | ---: | ---: | ---: | | Rust release | **2,178.2** | 2,166.6 | 2,111.1–2,212.4 | 1.88% | **8.8 MiB** | | Elixir/OTP | 2,136.9 | 2,135.8 | 2,108.9–2,157.5 | 0.74% | 109.8 MiB | | PHP, 16 processes | 1,297.9 | 1,301.5 | 1,283.2–1,321.2 | 0.98% | 132.5 MiB | | Go | 662.2 | 664.4 | 661.0–669.2 | 0.50% | 17.9 MiB | | TypeScript/Bun | 657.9 | 657.8 | 656.8–658.2 | **0.07%** | 79.9 MiB | | Python | 268.0 | 267.7 | 266.4–268.1 | 0.24% | 29.3 MiB | PHP memory is the aggregate RSS of all 16 worker processes. Its throughput and memory must therefore be read as a deployment topology, not as one PHP Worker. ### Why Worker results diverge This workload intentionally makes handlers shorter than the SDK polling interval. It exposes the control loop rather than application compute. The TypeScript Worker calculates `free = concurrency - active.size`. When no slot is free it sleeps for 20 ms before checking again. Go uses the same 20 ms fixed wait. Python waits 50 ms. Completed handlers cannot wake these sleeps, so new jobs are not pulled immediately when slots become free. With 16 slots and a 1 ms handler, the approximate refill ceilings are: | SDK loop | Fixed full-capacity wait | Approximate ceiling | Observed | | --- | ---: | ---: | ---: | | TypeScript | 20 ms | 800 jobs/s | 658 jobs/s | | Go | 20 ms | 800 jobs/s | 662 jobs/s | | Python | 50 ms | 320 jobs/s | 268 jobs/s | The remaining gap includes pull/ACK round trips, scheduling, handler execution, and imperfect alignment between slot completion and the polling timer. Rust pulls a bounded batch, joins its processing threads, and immediately begins the next iteration. Elixir similarly waits for its `Task.async_stream` batch and recurses directly into the next pull. Neither inserts a fixed success-path sleep between full batches. This explains their approximately 2.1K jobs/s without implying that their MessagePack codecs are three to eight times faster. PHP reaches roughly 1.3K jobs/s by scaling across 16 independent connections. That topology refills work independently but spends more aggregate memory and operating-system process resources. ## Highest-value SDK optimization The first SDK performance change should be event-driven slot refill in TypeScript, Go, and Python: 1. signal a condition, channel, semaphore, or promise when an active handler releases its slot; 2. let a full Worker wait on that signal instead of a fixed timer; 3. pull again immediately when capacity becomes available; 4. keep bounded backoff only for empty queues and transport errors; 5. evaluate ACK batching after removing the refill ceiling. This changes the worker loop from timer-quantized scheduling to capacity-triggered scheduling. It should matter most for short database, cache, HTTP, and event-routing handlers. Long-running or CPU-bound handlers will see less benefit because processing time already dominates the 20–50 ms wait. ## How to use these numbers Use the producer table to size ingestion-heavy services that send moderate payloads in bulk over a low-latency network. Use the worker table to compare SDK control-loop overhead for very short I/O handlers. Neither table predicts: - WAN performance, where network RTT can dominate; - large payloads, where serialization and copies become material; - durable-per-job writes, which change the persistence cost; - CPU-bound handlers, which need process/thread isolation; - queues dominated by delays, priorities, groups, dependencies, or retries; - horizontal broker scaling, because every sample uses one local broker. Do not extrapolate linearly from five-second-scale drains to 24/7 capacity. Production sizing also needs a sustained soak with the application's real payload distribution, handler latency, retry rate, durability mode, disk, and network. ## Correctness gate Before benchmarking, the complete isolated SDK gate passed sequentially: | SDK | Native tests | Protocol conformance | | --- | ---: | ---: | | TypeScript | 159 | 17/17 | | Python | 137 | 17/17 | | PHP | 65 | 17/17 | | Go | 72, 1 skipped | 17/17 | | Rust | 45, 1 skipped | 17/17 | | Elixir | 49, 1 skipped | 17/17 | The gate verifies package builds, native behavior, real broker integration, TLS, reconnect, leases, retries, flows, telemetry, and the shared protocol contract. It is separate from the native performance campaign: containers provide clean functional isolation, while only host-native runs are accepted as benchmark evidence. See [all bunqueue benchmarks](/guide/benchmarks/), the [SDK API guide](/guide/sdks/), and the [client SDK architecture](/architecture/client-sdk/) for the surrounding transport and worker design. --- # Server Mode: SQLite or PostgreSQL over TCP & HTTP Deploy bunqueue as one SQLite broker or a PostgreSQL 15–18 broker fleet, with TCP/HTTP APIs, token auth, Docker, and graceful shutdown. URL: https://bunqueue.dev/guide/server/
server · standalone

One server API. One or many brokers.

Run bunqueue as a standalone service so multiple apps can share a queue. Keep one memory/SQLite broker, or point several brokers at PostgreSQL 15–18; producers and workers use the same TCP API either way.

Embedded mode ties the queue to one Bun process. Server mode runs bunqueue standalone: your API adds jobs from one service, workers process them from another, and all six external SDKs join over the wire. Every broker listens on two ports: **6789** (TCP, the fast binary protocol clients use) and **6790** (HTTP, REST API and metrics). ## Start the server ```bash # Defaults: TCP 6789, HTTP 6790, in-memory storage bunqueue # With persistence and custom ports bunqueue start \ --tcp-port 6789 \ --http-port 6790 \ --data-path ./data/queue.db # PostgreSQL: use a unique BUNQUEUE_BROKER_ID in every active process BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@postgres:5432/bunqueue' \ BUNQUEUE_POSTGRES_NAMESPACE=production \ BUNQUEUE_BROKER_ID=broker-a \ bunqueue start ``` Always select durable storage in production: set a SQLite `--data-path`, or configure `BUNQUEUE_POSTGRES_URL` for the server-only multi-broker backend. Without either, jobs live in memory and are lost on restart. PostgreSQL 15–18 is tested in CI and 18.6 is recommended; see [Storage backends](/guide/databases/). For bounded completed-job history on SQLite, add `--completed-retention-ms ` (or the equivalent config/environment setting). `--max-completed-jobs` only sizes the in-memory hot window and does not reclaim disk space. ## Connect from your app Drop the `embedded` option and clients connect to `localhost:6789` automatically: ```typescript import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('tasks'); const worker = new Worker('tasks', async (job) => { console.log('Processing:', job.data); return { success: true }; }); await queue.add('my-job', { foo: 'bar' }); ``` For a remote server, pass a connection: ```typescript const queue = new Queue('tasks', { connection: { host: '192.168.1.100', port: 6789, token: 'my-secret-token', // Required if the server sets AUTH_TOKENS }, }); ``` Not on Bun? Use the [client SDKs](/guide/sdks/) for TypeScript on Node.js/Deno/Cloudflare Workers, Python, PHP, Go, Rust, or Elixir. ## Add authentication Without auth, anyone who can reach the port can control your queues. Set one or more tokens on the server: ```bash AUTH_TOKENS=secret1,secret2 bunqueue start --data-path ./data/queue.db ``` Every client then needs a matching `token` in its connection options. More hardening tips in [Security](/security/). ## Configure it The recommended way is a typed `bunqueue.config.ts` file in your project root, auto-discovered by `bunqueue start`: ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ server: { tcpPort: 6789, httpPort: 6790 }, auth: { tokens: ['my-secret-token'] }, storage: { dataPath: './data/queue.db' }, }); ``` See [Configuration File](/guide/configuration/) for every option. Environment variables work too, as a fallback: | Variable | Default | Description | | ----------------------------- | --------- | -------------------------------------------------------- | | `TCP_PORT` | `6789` | TCP server port | | `HTTP_PORT` | `6790` | HTTP server port | | `HOST` | `0.0.0.0` | Bind address | | `BUNQUEUE_STORAGE_DRIVER` | inferred | `memory`, `sqlite`, or `postgres` | | `BUNQUEUE_DATA_PATH` | (memory) | SQLite database path | | `BUNQUEUE_POSTGRES_URL` | (none) | PostgreSQL URL; selects `postgres` when no driver is set | | `BUNQUEUE_POSTGRES_NAMESPACE` | `default` | Isolates a bunqueue installation in one database | | `BUNQUEUE_BROKER_ID` | generated | Unique stable identity for each active PostgreSQL broker | | `AUTH_TOKENS` | (none) | Comma-separated auth tokens | | `LOG_FORMAT` | `text` | Log format (`text` / `json`) | Priority when the same option is set in more than one place: CLI flags > config file > environment variables > defaults. Full list in [Environment Variables](/guide/env-vars/). ## Run it in Docker Every completed release publishes the multi-arch image with the exact version tag alongside `latest` and the distribution variants. Pin the version tag when the server and client must move together: ```bash docker run -d -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ ghcr.io/egeominotti/bunqueue:2.9.4 ``` PostgreSQL storage needs a 2.9 image or newer: a 2.8.x image ignores the variables above and starts in memory or SQLite mode without an error. Confirm the tag you pin exists — a release whose pipeline did not complete pushes no image, and `2.9.0` is one such gap. `docker buildx imagetools inspect ghcr.io/egeominotti/bunqueue:` prints the digest a rollout should pin. To build an application-specific image instead: ```dockerfile FROM oven/bun:1.4.2-alpine WORKDIR /app COPY package.json bun.lock* ./ RUN bun install --production COPY . . EXPOSE 6789 6790 CMD ["bun", "run", "src/main.ts"] ``` ```bash docker build -t bunqueue . docker run -p 6789:6789 -p 6790:6790 \ -v ./data:/app/data \ -e BUNQUEUE_DATA_PATH=/app/data/queue.db \ bunqueue ``` More deployment recipes (systemd, Kubernetes, Fly.io) in the [deployment guide](/guide/deployment/). ## Graceful shutdown On `SIGINT` or `SIGTERM` the server: 1. Stops accepting new connections 2. Waits for active jobs to finish (30s timeout, configurable via `SHUTDOWN_TIMEOUT_MS`) 3. Flushes SQLite writes or drains admitted PostgreSQL operations and maintenance 4. Exits cleanly ## Connect AI agents (MCP) AI agents can drive a running server through the bundled MCP server, which talks to bunqueue over TCP: ```bash bunqueue start --data-path ./data/queue.db # In another terminal bun add bunqueue @modelcontextprotocol/sdk claude mcp add bunqueue -- bunx --package=bunqueue bunqueue-mcp ``` Point the MCP server at your instance with `BUNQUEUE_MODE=tcp`, `BUNQUEUE_HOST`, `BUNQUEUE_PORT`, and `BUNQUEUE_TOKEN` (when auth is on). Agents get 73 tools to add jobs, manage queues, schedule crons, and monitor everything. Full setup, including Claude Desktop, Cursor, and Windsurf config, in the [MCP guide](/guide/mcp/). :::tip[Related Guides] - [Environment Variables](/guide/env-vars/), all server configuration options - [CLI Commands](/guide/cli/), manage the server from the terminal - [Security Best Practices](/security/), secure your deployment - [Monitoring & Prometheus Metrics](/guide/monitoring/), watch server health ::: --- # bunqueue.config.ts: Typed Server Configuration File Centralize every bunqueue server setting in one typed bunqueue.config.ts: ports, auth, SQLite or PostgreSQL 15–18 storage, CORS, backups, and timeouts. URL: https://bunqueue.dev/guide/configuration/
server · configuration

Server configuration in one typed file.

Configure the whole bunqueue server from a single typed bunqueue.config.ts instead of scattered environment variables. Every option has IntelliSense, every section is optional.

This page is about configuring the **standalone server** ([Server Mode](/guide/server/)). Embedded mode needs no config file, it takes options directly in the `Queue`/`Worker` constructors. ## Quick start Create a `bunqueue.config.ts` in your project root: ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ server: { tcpPort: 6789, httpPort: 6790, }, storage: { dataPath: './data/queue.db', }, }); ``` Then start normally: ```bash bunqueue start ``` The config file is **auto-discovered**, no flags needed. `defineConfig()` gives you full TypeScript IntelliSense, so you never have to guess an option name. ## Priority order When the same option is set in more than one place, the first of these wins: 1. **CLI flags**, `bunqueue start --tcp-port 8000` 2. **Config file**, `bunqueue.config.ts` 3. **Environment variables**, `TCP_PORT=8000` 4. **Built-in defaults** Use the config file as your baseline and override per environment with env vars or flags. ## Picking a config file bunqueue looks in your project root for `bunqueue.config.ts`, then `bunqueue.config.js`, then `bunqueue.config.mjs`. To use a specific file: ```bash bunqueue start --config ./config/production.config.ts # Short form bunqueue start -c ./config/staging.config.ts ``` ## Full configuration reference Every section is **optional**. Only specify what you need. ### `server` TCP and HTTP server settings. ```typescript defineConfig({ server: { tcpPort: 6789, // TCP server port (default: 6789) httpPort: 6790, // HTTP/REST API port (default: 6790) host: '0.0.0.0', // Bind address (default: 0.0.0.0) tcpSocketPath: undefined, // Reserved, not applied yet: TCP always binds host:port httpSocketPath: undefined, // Unix socket for HTTP (overrides host/port) tlsCertFile: undefined, // PEM certificate, enables native TLS on TCP + HTTP (with tlsKeyFile) tlsKeyFile: undefined, // PEM private key (set both or neither, partial config is a startup error) }, }); ``` ### `auth` Authentication tokens for clients. Set this on any server reachable from a network. ```typescript defineConfig({ auth: { tokens: ['my-secret-token'], // Auth tokens for TCP/HTTP requireAuthForMetrics: false, // Require auth for /prometheus (env: METRICS_AUTH) }, }); ``` :::note[Secrets] The config file is code, don't hardcode secrets that get committed to git. Use `process.env.*` for sensitive values. ::: ### `storage` Where jobs persist. Memory and SQLite remain the defaults; PostgreSQL is an optional standalone-server backend for multiple active brokers. ```typescript defineConfig({ storage: { driver: 'sqlite', // 'memory' | 'sqlite' | 'postgres' dataPath: './data/queue.db', // required for explicit SQLite maxCompletedJobs: 50_000, // completed-job hot cache/recovery window completedRetentionMs: 7 * 24 * 60 * 60 * 1000, // optional durable retention }, }); ``` `maxCompletedJobs` bounds the in-memory completed-job projection; it does not delete SQLite rows. Set `completedRetentionMs` to opt into age-based durable cleanup (up to 1,000 oldest eligible rows per 10-second cleanup tick). The default is `null`, so completed rows remain until `queue.clean(...)`, `obliterate`, or another explicit policy removes them. Results still needed by live dependency consumers are protected until the consumer leaves the graph. Finite non-negative values are rounded down to whole milliseconds. Negative, non-finite, and unsafe integer values disable automatic retention (`null`) in both server configuration and direct embedded `QueueManager` construction. The server CLI equivalents are `--max-completed-jobs` and `--completed-retention-ms`; environment equivalents are documented in the [environment reference](/guide/env-vars/). Without `driver`, a PostgreSQL URL selects PostgreSQL, a data path selects SQLite, and neither selects in-memory storage. PostgreSQL configuration: ```typescript defineConfig({ storage: { driver: 'postgres', url: process.env.BUNQUEUE_POSTGRES_URL!, namespace: 'production', // isolates installations sharing one database brokerId: process.env.HOSTNAME, // unique per active broker; auto-generated if omitted poolSize: 4, // default 4, runtime minimum 2 leaseDurationMs: 30_000, // default 30s, runtime minimum 1s pollIntervalMs: 250, // durable event/cron fallback, minimum 25ms statementTimeoutMs: 30_000, lockTimeoutMs: 5_000, idleTransactionTimeoutMs: 30_000, maxConcurrentOperations: 16, maxQueuedOperations: 128, maxSnapshotJobs: 100_000, maxSnapshotPayloadBytes: 256 * 1024 * 1024, }, }); ``` Do not combine `url` with `dataPath`. PostgreSQL support is server-only and is validated against PostgreSQL 15, 16, 17, and 18.6; embedded queues continue to use memory/SQLite. MySQL is not supported. See [Storage backends](/guide/databases/). ### `telemetry` Bound labelled Prometheus output independently from the exact global totals: ```typescript defineConfig({ telemetry: { maxPrometheusQueues: 100, // 0 disables per-queue label series }, }); ``` The environment equivalent is `METRICS_MAX_QUEUES`. The default is `100`; invalid or negative values fall back to the default. ### `cors` Allowed origins for browser access to the HTTP API. ```typescript defineConfig({ cors: { origins: ['https://myapp.com', 'https://admin.myapp.com'], }, }); ``` ### `backup` Automatic snapshots of the SQLite database to any S3-compatible storage (AWS, MinIO, Cloudflare R2). See [S3 Backup](/guide/backup/). ```typescript defineConfig({ backup: { enabled: true, bucket: 'my-bunqueue-backups', accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, sessionToken: process.env.S3_SESSION_TOKEN, // Temporary credentials region: 'eu-west-1', // Default: us-east-1 endpoint: undefined, // Custom S3 endpoint (MinIO, R2, etc.) virtualHostedStyle: undefined, // Force bucket-in-host addressing interval: 6 * 60 * 60 * 1000, // Backup interval in ms (default: 6h) retention: 7, // Backups to keep (default: 7) prefix: 'backups/', // S3 key prefix (default: 'backups/') }, }); ``` The server also needs `storage.dataPath` (or a data-path environment variable); automatic backup is unavailable in in-memory and PostgreSQL modes. ### `timeouts` ```typescript defineConfig({ timeouts: { shutdown: 30000, // Graceful shutdown timeout in ms (default: 30000) stats: 300000, // Stats logging interval in ms (default: 300000) }, }); ``` Only `shutdown` and `stats` are read from the config file. The type also accepts `worker` and `lock`, but those values are currently ignored — set the `WORKER_TIMEOUT_MS` and `LOCK_TIMEOUT_MS` environment variables instead. ### `webhooks` Delivery retries for [webhooks](/guide/webhooks/) are configured via the `WEBHOOK_MAX_RETRIES` (default: 3) and `WEBHOOK_RETRY_DELAY_MS` (default: 1000) environment variables. The config-file type accepts a `webhooks` key for forward compatibility, but its values are currently ignored. ### `logging` ```typescript defineConfig({ logging: { level: 'info', // 'debug' | 'info' | 'warn' | 'error' format: 'json', // 'text' | 'json' }, }); ``` ## Complete examples ### Development ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ storage: { dataPath: './data/dev.db' }, logging: { level: 'debug' }, }); ``` ### Production ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ server: { tcpPort: 6789, httpPort: 6790, host: '0.0.0.0' }, auth: { tokens: [process.env.BUNQUEUE_AUTH_TOKEN!], requireAuthForMetrics: true, }, storage: { dataPath: '/data/bunqueue/queue.db' }, telemetry: { maxPrometheusQueues: 100 }, cors: { origins: [process.env.FRONTEND_URL!] }, backup: { enabled: true, bucket: process.env.S3_BUCKET!, accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, region: 'eu-west-1', interval: 3600000, // Every hour retention: 30, }, logging: { level: 'info', format: 'json' }, timeouts: { shutdown: 60000 }, }); ``` ### Docker / Kubernetes Mix the config file (static settings baked into the image) with environment variables (per-deployment values). Remember: when both define the same option, the config file wins. ```typescript // bunqueue.config.ts, static settings in the image import { defineConfig } from 'bunqueue'; export default defineConfig({ server: { host: '0.0.0.0' }, logging: { format: 'json' }, backup: { enabled: true, region: 'eu-west-1' }, }); ``` ```bash # Dynamic settings fill what the config file leaves unset docker run \ -e TCP_PORT=6789 \ -e S3_BUCKET=my-bucket \ -e S3_ACCESS_KEY_ID=xxx \ -e S3_SECRET_ACCESS_KEY=xxx \ my-bunqueue-image ``` ## Importing `defineConfig` Available from both package exports: ```typescript import { defineConfig } from 'bunqueue'; // or import { defineConfig } from 'bunqueue/client'; ``` ## bunqueue Cloud :::caution[Beta Coming Soon] bunqueue Cloud is launching in beta soon. Once the dashboard is live, you'll connect instances with the `cloud` section, no code changes needed. ::: ```typescript defineConfig({ cloud: { url: 'https://cloud.bunqueue.io', apiKey: process.env.BUNQUEUE_CLOUD_API_KEY, instanceId: process.env.BUNQUEUE_CLOUD_INSTANCE_ID, }, }); ``` :::tip[Related Guides] - [Environment Variables](/guide/env-vars/), full env var reference (still supported as fallback) - [Running the Server](/guide/server/), server startup guide - [S3 Backup](/guide/backup/), backup configuration details ::: --- # CLI: Run and Manage bunqueue from the Terminal The bunqueue CLI starts the server and talks to a running one: push and process jobs, manage the DLQ and cron, and script everything with JSON output. URL: https://bunqueue.dev/guide/cli/
server · cli

The queue, from the CLI.

One binary, two roles: bunqueue start runs the server, every other command talks to a running one. Push, pull, ack, DLQ, cron, backups, and monitoring, all scriptable with JSON output.

## Start the Server ```bash bunqueue start # defaults: TCP 6789, HTTP 6790 bunqueue start --tcp-port 7000 --http-port 7001 # custom ports bunqueue start --host 127.0.0.1 -p 6789 # bind to a specific host bunqueue start --data-path ./data/production.db # persistent storage bunqueue start --data-path ./data/production.db \ --completed-retention-ms 604800000 # retain completions for 7 days AUTH_TOKENS=secret-token bunqueue start # with authentication bunqueue start --config ./bunqueue.config.ts # with a config file ``` On startup the server prints its ports, data path, and enabled features (TLS, auth, S3 backup, cloud, shard count). `--max-completed-jobs` bounds only the completed-job hot cache (default 50,000). `--completed-retention-ms` separately opts SQLite into durable age-based cleanup; without it, completed rows remain until an explicit clean or obliterate operation. :::tip[Configuration File] Instead of CLI flags and env vars, you can centralize all settings in a typed `bunqueue.config.ts`. See [Configuration File](/guide/configuration/). ::: ## Connect to a Server Client commands default to `localhost:6789`: ```bash bunqueue stats # local server bunqueue stats --host 192.168.1.100 --port 6789 # remote server bunqueue stats --token secret-token # with authentication ``` Set the token once via environment variable instead of repeating the flag. Priority: `--token` flag > `BQ_TOKEN` > `BUNQUEUE_TOKEN`. ```bash export BQ_TOKEN=my-secret-token ``` ## Push, Pull, Ack, Fail The core loop: add a job, take it, and report the outcome. ```bash bunqueue push emails '{"to":"user@example.com","subject":"Welcome"}' # Job created: 019ce9d7-6983-7000-946f-48737be2b0f9 ``` Job IDs are UUID v7 strings (time-ordered). Push accepts options for priority, retries, deduplication, and more: ```bash bunqueue push emails '{"to":"vip@example.com"}' --priority 10 # higher = sooner bunqueue push notifications '{"msg":"hi"}' --delay 5000 # run in 5s bunqueue push orders '{"orderId":"ORD-123"}' --job-id order-ORD-123 # idempotent ID bunqueue push emails '{"to":"a@b.c"}' --max-attempts 5 --backoff 2000 # retry config bunqueue push notifications '{"userId":"1"}' -u user-1-notify # unique key (dedup) bunqueue push aggregate '{"type":"sum"}' --depends-on job-1,job-2 # wait for other jobs ``` | Option | Short | Default | Description | |--------|-------|---------|-------------| | `--priority` | `-P` | `0` | Higher = processed first | | `--delay` | `-d` | `0` | Delay in ms before processing | | `--job-id` | - | - | Custom ID for deduplication | | `--max-attempts` | - | `3` | Max retry attempts | | `--backoff` | - | `1000` | Delay between retries (ms) | | `--ttl` | - | - | Time-to-live in ms | | `--timeout` | - | - | Processing timeout in ms | | `--unique-key` | `-u` | - | Deduplication key | | `--depends-on` | - | - | Comma-separated job IDs to wait for | | `--tags` | - | - | Comma-separated tags | | `--group-id` | `-g` | - | Group identifier | | `--lifo` | - | `false` | Last in, first out ordering | | `--remove-on-complete` | - | `false` | Auto-delete on completion | | `--remove-on-fail` | - | `false` | Auto-delete on failure | Pull the next job (typically a worker's job, but handy for debugging): ```bash bunqueue pull emails # prints the job, or "No job available" bunqueue pull emails --timeout 5000 # wait up to 5s for a job ``` Then acknowledge (mark done) or fail it: ```bash bunqueue ack 019ce9d7-... --result '{"delivered":true}' # result retrievable later bunqueue fail 019ce9d7-... --error "SMTP connection timeout" ``` A failed job is retried with backoff while attempts remain, then moved to the [DLQ](/guide/dlq/). ## Inspect and Control Jobs ```bash bunqueue job get # full details (use --json for the raw object) bunqueue job state # just the state bunqueue job result # the stored result bunqueue job logs # log entries attached to the job bunqueue job cancel # cancel a waiting/delayed job bunqueue job promote # run a delayed job now bunqueue job discard # send a job to the DLQ bunqueue job progress 50 --message "Halfway" # update progress (active jobs) bunqueue job update '{"to":"new@example.com"}' # replace job data bunqueue job priority 20 # change priority bunqueue job delay 60000 # move an active job back to delayed bunqueue job wait --timeout 30000 # block until completed, print result bunqueue job log "Checkpoint reached" --level info # append a log entry ``` Commands print `OK` on success, or `Error: Job not found ...` with exit code 1. `job wait` exits 1 if the job does not complete within the timeout. ## Queue Control ```bash bunqueue queue list # list all queues bunqueue queue count emails # total jobs in a queue bunqueue queue pause emails # workers stop picking new jobs bunqueue queue resume emails bunqueue queue paused emails # prints "Queue is paused" or "Queue is active" bunqueue queue jobs emails --state waiting --limit 10 # list jobs by state # states: waiting, delayed, active, completed, failed (--offset for pagination) bunqueue queue clean emails --grace 3600000 --state completed # remove old jobs # default state when omitted: waiting/delayed; --limit caps per call (default 1000) bunqueue queue drain emails # remove all waiting jobs (active ones keep running) bunqueue queue obliterate emails # remove EVERYTHING for this queue ``` ## DLQ Inspect and recover permanently failed jobs (see [Dead Letter Queue](/guide/dlq/)): ```bash bunqueue dlq list emails # entries with error and timestamp (--count 10) bunqueue dlq retry emails # re-queue all, prints the count moved bunqueue dlq retry emails --id # re-queue one bunqueue dlq purge emails # delete all entries, prints the count ``` ## Cron Schedule recurring jobs (see [Cron Jobs](/guide/cron/)): ```bash # Cron expression: daily at 6 AM (optionally --timezone/-z Europe/Rome) bunqueue cron add daily-report -q reports -d '{"type":"daily"}' -s "0 6 * * *" # Cron scheduled: daily-report (next run: 2024-01-16T06:00:00.000Z) # Plain interval: every 30 minutes bunqueue cron add health-check -q health -d '{"check":"all"}' -e 1800000 bunqueue cron list # name, queue, schedule, executions, next run bunqueue cron delete daily-report ``` ## Rate and Concurrency Limits ```bash bunqueue rate-limit set emails 100 # max 100 jobs/second bunqueue concurrency set emails 10 # max 10 concurrent jobs bunqueue rate-limit clear emails bunqueue concurrency clear emails ``` ## Monitoring ```bash bunqueue ping # quickest TCP liveness check (works, though not listed in --help) bunqueue stats # waiting/active/delayed/completed/failed/DLQ counts, uptime, rates bunqueue metrics # Prometheus text format, same as GET /prometheus bunqueue health # alias of stats over TCP bunqueue version # client + server version, warns on mismatch ``` For a JSON health payload (status, version, memory, connections), use the HTTP endpoint: `curl http://localhost:6790/health`. `bunqueue doctor` runs a full diagnostic: client and server version, reachability, health status, uptime, connections, queue counts, and memory. It prints a check-by-check report and `All checks passed.` when healthy. Use `--host`/`--port` to check a remote server. ## Workers and Webhooks ```bash bunqueue worker list # registered workers with status bunqueue worker register email-worker -q emails,notifications bunqueue worker unregister w-abc123 ``` :::caution CLI worker registrations are transient: the server unregisters a worker when its TCP connection closes, and the one-shot CLI process exits immediately (the CLI warns about this). For persistent workers, run a long-lived process with the SDK `Worker` class. ::: ```bash bunqueue webhook list bunqueue webhook add https://example.com/hooks -e job.completed,job.failed -q emails # Webhook added: (keep the ID for webhook remove) bunqueue webhook remove ``` `--events` (`-e`) is required; valid events are `job.pushed`, `job.started`, `job.completed`, `job.failed`, `job.progress`. Optional: `--queue`/`-q` filter and `--secret`/`-s` HMAC secret. See [Webhooks](/guide/webhooks/). ## Backups Backup commands run **locally**, not through the TCP server: they require a persistent database path from `BUNQUEUE_DATA_PATH` (or its aliases) and read credentials from the `S3_*` environment variables, including temporary `S3_SESSION_TOKEN` credentials when used (see [S3 Backup](/guide/backup/)). ```bash bunqueue backup now # create a backup, prints key/size/duration bunqueue backup list # list backups in the bucket bunqueue backup status # show configuration bunqueue backup restore -f # restore; requires --force, stop the server first ``` Stopping is mandatory for restore. The command validates a temporary candidate and quarantines stale SQLite WAL/SHM sidecars, but it cannot invalidate a database handle held by a running server. ## Global Options | Option | Short | Description | Default | |--------|-------|-------------|---------| | `--host` | `-H` | Server hostname | `localhost` | | `--port` | `-p` | TCP port | `6789` | | `--token` | `-t` | Authentication token (env: `BQ_TOKEN`, `BUNQUEUE_TOKEN`) | - | | `--tls` | - | Connect with TLS (verify with system CAs) | `false` | | `--tls-ca ` | - | Trust a custom CA cert (implies `--tls`) | - | | `--tls-no-verify` | - | TLS without cert verification (self-signed, dev only) | `false` | | `--json` | - | Output as JSON | `false` | | `--help` | - | Show help | - | | `--version` | - | Show version | - | :::note Two subcommands define their own short `-t` (`--timeout`): `pull` and `job wait`. There, use the long `--token` form. ::: ## Scripting with JSON Every command supports `--json`. It prints the raw server response (`{ "ok": true, ... }`), so nest your `jq` path under the response field (`.stats`, `.jobs`, `.job`, `.counts`, ...): ```bash bunqueue stats --json | jq '.stats.waiting' # 234 ``` Process a job manually: ```bash JOB=$(bunqueue pull emails --json) # { "ok": true, "job": { ... } } JOB_ID=$(echo $JOB | jq -r '.job.id') echo "Processing job $JOB_ID..." # your logic here bunqueue ack $JOB_ID --result '{"processed":true}' ``` Daily maintenance script: ```bash #!/bin/bash bunqueue queue clean emails --grace 86400000 --state completed bunqueue dlq purge emails bunqueue backup now ``` --- # bunqueue Environment Variables Reference Complete environment variable reference for bunqueue, including SQLite and PostgreSQL 15–18 storage, ports, auth, backups, timeouts, and logging. URL: https://bunqueue.dev/guide/env-vars/
server · environment

Every environment variable, one page.

The complete environment variable reference for the bunqueue server and CLI: ports, storage, auth, TLS, S3 backup, timeouts, and logging.

:::tip[Prefer a config file?] A typed `bunqueue.config.ts` can replace most of these, with IntelliSense and everything in one place. See [Configuration File](/guide/configuration/). Environment variables still work as a fallback (priority: CLI flags > config file > env vars > defaults). ::: ## Server & storage | Variable | Type | Default | Description | | ----------------------------------------------- | -------------------- | ----------- | ------------------------------------------------------------------------------ | | `TCP_PORT` | number | `6789` | TCP server port for client connections | | `HTTP_PORT` | number | `6790` | HTTP server port for REST API and metrics | | `HOST` | string | `0.0.0.0` | Bind address (`127.0.0.1` for local-only) | | `BUNQUEUE_STORAGE_DRIVER` | string | inferred | `memory`, `sqlite`, or `postgres` | | `BUNQUEUE_DATA_PATH` | string | (in-memory) | SQLite database path. Without it or a PostgreSQL URL, jobs are lost on restart | | `BUNQUEUE_MAX_COMPLETED_JOBS` | positive integer | `50000` | Completed-job hot cache/recovery window; does not delete durable rows | | `BUNQUEUE_COMPLETED_RETENTION_MS` | non-negative integer | disabled | Age after which the background cleanup may delete completed SQLite rows | | `BUNQUEUE_POSTGRES_URL` | string | (none) | PostgreSQL connection URL; implies the `postgres` driver when no driver is set | | `BUNQUEUE_POSTGRES_NAMESPACE` | string | `default` | Isolates independent bunqueue installations in one PostgreSQL database | | `BUNQUEUE_BROKER_ID` | string | generated | Stable unique ID for this PostgreSQL broker process | | `BUNQUEUE_POSTGRES_POOL_SIZE` | positive integer | `4` | PostgreSQL pool size (runtime minimum `2`) | | `BUNQUEUE_POSTGRES_LEASE_DURATION_MS` | positive integer | `30000` | Default database-clock lease duration (runtime minimum `1000`) | | `BUNQUEUE_POSTGRES_POLL_INTERVAL_MS` | positive integer | `250` | Event/cron fallback polling interval (runtime minimum `25`) | | `BUNQUEUE_POSTGRES_STATEMENT_TIMEOUT_MS` | positive integer | `30000` | Maximum PostgreSQL statement duration | | `BUNQUEUE_POSTGRES_LOCK_TIMEOUT_MS` | positive integer | `5000` | Maximum wait for a PostgreSQL lock | | `BUNQUEUE_POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS` | positive integer | `30000` | Maximum idle time inside a transaction | | `BUNQUEUE_POSTGRES_MAX_CONCURRENT_OPERATIONS` | positive integer | `16` | Active PostgreSQL manager operations per broker | | `BUNQUEUE_POSTGRES_MAX_QUEUED_OPERATIONS` | non-negative integer | `128` | Waiting PostgreSQL manager operations before fail-fast saturation | | `BUNQUEUE_POSTGRES_MAX_SNAPSHOT_JOBS` | positive integer | `100000` | Maximum job/result entities in one compatibility snapshot | | `BUNQUEUE_POSTGRES_MAX_SNAPSHOT_PAYLOAD_BYTES` | positive integer | `268435456` | Maximum encoded bytes in one compatibility snapshot | | `HTTP_SOCKET_PATH` | string | (none) | Unix socket for the HTTP server, replaces `HTTP_PORT` | | `TCP_SOCKET_PATH` | string | (none) | **Reserved, not functional yet** (see below) | | `TLS_CERT_FILE` | string | (none) | PEM certificate, enables native TLS on TCP + HTTP | | `TLS_KEY_FILE` | string | (none) | PEM private key matching `TLS_CERT_FILE` | ```bash BUNQUEUE_DATA_PATH=/var/lib/queue.db TCP_PORT=6789 bunqueue start ``` **Data path aliases.** Four names are read for the SQLite path, in priority order: `BUNQUEUE_DATA_PATH` > `BQ_DATA_PATH` > `DATA_PATH` > `SQLITE_PATH`. They are equivalent; prefer `BUNQUEUE_DATA_PATH`. **Completed-job retention.** `BUNQUEUE_MAX_COMPLETED_JOBS` (legacy alias: `MAX_COMPLETED_JOBS`) only bounds the hot in-memory projection. Durable retention is opt-in through `BUNQUEUE_COMPLETED_RETENTION_MS` (legacy alias: `COMPLETED_RETENTION_MS`); when unset, completed SQLite rows are retained until an explicit clean or obliterate operation. `0` makes every unprotected completed row eligible on the next cleanup tick. **Storage selection.** An explicit driver wins. Otherwise a PostgreSQL URL selects PostgreSQL, a data path selects SQLite, and neither selects memory. PostgreSQL and a SQLite data path cannot be combined. PostgreSQL is server-only, tested in CI against majors 15, 16, 17, and the pinned/recommended 18.6 release, and every active broker sharing a namespace must have a unique broker ID. MySQL is not supported. In the repository Compose topology, `POSTGRES_PASSWORD` configures the database and `BUNQUEUE_POSTGRES_URL` configures brokers; if the secret contains URI-reserved characters, percent-encode its password component in the URL. ```bash BUNQUEUE_STORAGE_DRIVER=postgres \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@postgres:5432/bunqueue' \ BUNQUEUE_POSTGRES_NAMESPACE=production \ BUNQUEUE_BROKER_ID=broker-a \ bunqueue start ``` **TLS.** Set both `TLS_CERT_FILE` and `TLS_KEY_FILE` or neither, setting only one is a startup error (fail fast, never silent plaintext). See the [TLS guide](/guide/tls/). :::caution[`TCP_SOCKET_PATH` is not functional yet] The variable is accepted and shown in the startup banner, but the TCP listener always binds `HOST:TCP_PORT` today. Use `HTTP_SOCKET_PATH` for Unix-socket access (HTTP API), or bind to `HOST=127.0.0.1` for local-only access. ::: ## Authentication & security | Variable | Type | Default | Description | | ----------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------- | | `AUTH_TOKENS` | string | (none) | Comma-separated tokens for every TCP connection and protected HTTP endpoint; health probes stay public | | `BQ_TOKEN` / `BUNQUEUE_TOKEN` | string | (none) | Default token for CLI client commands (avoids `--token` on every command) | | `METRICS_AUTH` | boolean | `false` | Require auth for `/prometheus`. Only `true` enables it; without `AUTH_TOKENS`, the endpoint returns 503 | | `METRICS_MAX_QUEUES` | integer | `100` | Maximum queue names exposed as Prometheus label values; `0` disables per-queue series | | `CORS_ALLOW_ORIGIN` | string | (none) | Comma-separated allowed CORS origins for the HTTP API | ```bash # Server side AUTH_TOKENS=secret-token-1,secret-token-2 bunqueue start # Client side, every protected request must carry a token bunqueue push emails '{"to":"test@example.com"}' --token secret-token-1 curl -H "Authorization: Bearer secret-token-1" http://localhost:6790/queues # Or set it once for the CLI (priority: --token flag > BQ_TOKEN > BUNQUEUE_TOKEN) export BQ_TOKEN=secret-token-1 bunqueue stats ``` The JSON `/metrics` endpoint is already covered by the general `AUTH_TOKENS` check; `METRICS_AUTH` adds the same requirement to `/prometheus`. Enabling it without configuring any token fails closed with 503. ## Logging | Variable | Type | Default | Values | | ------------ | ------ | ------- | -------------------------------- | | `LOG_LEVEL` | string | `info` | `debug`, `info`, `warn`, `error` | | `LOG_FORMAT` | string | `text` | `text`, `json` | ```bash LOG_LEVEL=debug LOG_FORMAT=json bunqueue start ``` JSON output looks like: ```json { "timestamp": "2024-01-15T10:30:00.000Z", "level": "info", "component": "Server", "message": "Received SIGTERM, shutting down..." } ``` Structured fields appear nested under a `data` key; the startup banner itself is plain text, not a JSON record. ## S3 backup Automatic snapshots of the SQLite database to any S3-compatible storage. Full guide: [S3 Backup](/guide/backup/). | Variable | Type | Default | Description | | ------------------------- | ------- | ---------------- | ------------------------------------------------------------- | | `S3_BACKUP_ENABLED` | boolean | `false` | Enable automated backups (`1` / `true`) | | `S3_BUCKET` | string | (none) | Bucket name (alias: `AWS_BUCKET`) | | `S3_ACCESS_KEY_ID` | string | (none) | Access key (alias: `AWS_ACCESS_KEY_ID`) | | `S3_SECRET_ACCESS_KEY` | string | (none) | Secret key (alias: `AWS_SECRET_ACCESS_KEY`) | | `S3_SESSION_TOKEN` | string | (none) | Temporary credential token (alias: `AWS_SESSION_TOKEN`) | | `S3_REGION` | string | `us-east-1` | Region (alias: `AWS_REGION`) | | `S3_ENDPOINT` | string | (none) | Custom endpoint for non-AWS providers (alias: `AWS_ENDPOINT`) | | `S3_VIRTUAL_HOSTED_STYLE` | boolean | provider default | Force bucket-in-host addressing (`1` / `true`) | | `S3_BACKUP_INTERVAL` | number | `21600000` (6h) | Interval between backups in ms | | `S3_BACKUP_RETENTION` | number | `7` | Number of backups to keep | | `S3_BACKUP_PREFIX` | string | `backups/` | Key prefix for backup files | Backups require a persistent SQLite data path (`BUNQUEUE_DATA_PATH`, `BQ_DATA_PATH`, `DATA_PATH`, or `SQLITE_PATH`). There is no file to snapshot in in-memory mode, and the built-in snapshot facility does not back up PostgreSQL. Enabling it without persistent SQLite fails server startup before binding TCP/HTTP. ```bash # Cloudflare R2 S3_ENDPOINT=https://abc123.r2.cloudflarestorage.com S3_BACKUP_ENABLED=1 bunqueue start # MinIO S3_ENDPOINT=http://localhost:9000 S3_BACKUP_ENABLED=1 bunqueue start ``` ## Timeouts & limits | Variable | Type | Default | Description | | ---------------------------- | ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SHUTDOWN_TIMEOUT_MS` | number | `30000` | How long graceful shutdown waits for active jobs | | `STATS_INTERVAL_MS` | number | `300000` | Stats logging interval | | `WORKER_TIMEOUT_MS` | number | `30000` | Worker-registration freshness window. Older heartbeats mark a worker stale; cleanup removes it after 3× this value | | `LOCK_TIMEOUT_MS` | number | `5000` | Timeout for acquiring internal locks | | `WORKER_CLEANUP_INTERVAL_MS` | number | `60000` | Interval for removing inactive worker registrations | | `TCP_IDLE_TIMEOUT_MS` | number | `60000` | Slowloris mitigation: close a connection that starts a frame but makes no progress within this window. Idle connections with no partial frame are never affected. `0` disables | | `TCP_MAX_WRITE_QUEUE_BYTES` | number | `67108864` (64 MB) | Max bytes buffered per connection's outbound queue before it is dropped (protects against clients that stop reading). `0` disables | ## Webhooks | Variable | Type | Default | Description | | ------------------------ | ------ | ------- | ------------------------------ | | `WEBHOOK_MAX_RETRIES` | number | `3` | Max delivery retry attempts | | `WEBHOOK_RETRY_DELAY_MS` | number | `1000` | Delay between delivery retries | ## Server rate limiting Protects the server itself from misbehaving clients (per TCP connection or HTTP client IP). Unrelated to per-queue job rate limiting, which is set via the [Queue API](/guide/rate-limiting/). | Variable | Type | Default | Description | | ------------------------- | ------ | ------- | ----------------------------------------- | | `RATE_LIMIT_MAX_REQUESTS` | number | `10000` | Max requests per client within the window | | `RATE_LIMIT_WINDOW_MS` | number | `60000` | Window duration | | `RATE_LIMIT_CLEANUP_MS` | number | `60000` | Cleanup interval for tracking data | ## Monitoring thresholds These control the real-time monitoring events (`queue:idle`, `queue:threshold`, `worker:overloaded`, `server:memory-warning`, `storage:size-warning`) delivered over WebSocket/SSE. See the [HTTP API events reference](/api/http/#explicit-subscription-events-86). | Variable | Default | Description | | ------------------------------ | -------------- | --------------------------------------------------------------------------------------- | | `QUEUE_IDLE_THRESHOLD_MS` | `30000` | Emit `queue:idle` when a queue is empty with no active jobs for this long. `0` disables | | `QUEUE_SIZE_THRESHOLD` | `0` (disabled) | Emit `queue:threshold` when a queue's waiting count reaches this size | | `WORKER_OVERLOAD_THRESHOLD_MS` | `30000` | Emit `worker:overloaded` when a worker stays at max concurrency for this long | | `MEMORY_WARNING_MB` | `0` (disabled) | Emit `server:memory-warning` when heap usage exceeds this many MB | | `STORAGE_WARNING_MB` | `0` (disabled) | Emit `storage:size-warning` when the SQLite database exceeds this many MB | ## bunqueue Cloud Telemetry agent for the bunqueue Cloud dashboard. Cloud mode activates only when `BUNQUEUE_CLOUD_URL`, `BUNQUEUE_CLOUD_API_KEY`, **and** `BUNQUEUE_CLOUD_INSTANCE_ID` are all set. | Variable | Default | Description | | ------------------------------------------ | -------- | ---------------------------------------------------------------- | | `BUNQUEUE_CLOUD_URL` | (none) | Cloud dashboard URL. Required for cloud mode | | `BUNQUEUE_CLOUD_API_KEY` | (none) | API key. Required for cloud mode | | `BUNQUEUE_CLOUD_INSTANCE_ID` | (none) | Unique instance identifier. Required for cloud mode | | `BUNQUEUE_CLOUD_INSTANCE_NAME` | hostname | Display name for this instance | | `BUNQUEUE_CLOUD_SIGNING_SECRET` | (none) | HMAC signing secret for payloads | | `BUNQUEUE_CLOUD_INTERVAL_MS` | `15000` | Snapshot upload interval in ms | | `BUNQUEUE_CLOUD_INCLUDE_JOB_DATA` | `true` | Include job payloads in telemetry. Set `false` for metadata only | | `BUNQUEUE_CLOUD_REDACT_FIELDS` | (none) | Comma-separated payload fields to redact | | `BUNQUEUE_CLOUD_EVENTS` | (all) | Comma-separated event filter | | `BUNQUEUE_CLOUD_BUFFER_SIZE` | `720` | Snapshot buffer size while offline | | `BUNQUEUE_CLOUD_CIRCUIT_BREAKER_THRESHOLD` | `5` | Consecutive failures before the circuit breaker opens | | `BUNQUEUE_CLOUD_CIRCUIT_BREAKER_RESET_MS` | `60000` | Circuit breaker reset window in ms | | `BUNQUEUE_CLOUD_USE_WEBSOCKET` | `true` | Stream via WebSocket. Set `false` to disable | | `BUNQUEUE_CLOUD_USE_HTTP` | `true` | Upload via HTTP. Set `false` to disable | | `BUNQUEUE_CLOUD_REMOTE_COMMANDS` | `true` | Allow remote commands from the dashboard. Set `false` to disable | ## Client & CLI | Variable | Type | Default | Description | | -------------------- | ------ | ----------- | ---------------------------------------------------------------------------- | | `BUNQUEUE_MODE` | string | `embedded` | Connection mode for the MCP server (`embedded` or `tcp`) | | `BUNQUEUE_HOST` | string | `localhost` | Server host for the MCP server in TCP mode; also a CLI fallback for `--host` | | `BUNQUEUE_PORT` | number | `6789` | Server port for the MCP server in TCP mode | | `BUNQUEUE_POOL_SIZE` | number | `2` | Connection pool size for the MCP server in TCP mode | | `BUNQUEUE_EMBEDDED` | string | (none) | Set to `1` to force embedded mode for the client library | | `NO_COLOR` | string | (none) | Set to `1` to disable colored CLI output | ```bash # Point the MCP server at a remote bunqueue instance BUNQUEUE_MODE=tcp BUNQUEUE_HOST=your-server.com BUNQUEUE_PORT=7000 bunx --package=bunqueue bunqueue-mcp ``` The MCP server also reads `BUNQUEUE_TOKEN` for authentication. **CLI port fallback.** When `--port` is not passed, the CLI reads, in priority order: `TCP_PORT` > `BUNQUEUE_TCP_PORT` > `BQ_TCP_PORT`. Using `TCP_PORT` means the same variable that binds the server also routes the client in the same shell: ```bash export TCP_PORT=7000 bunqueue stats # connects to localhost:7000 ``` **CLI host fallback.** When `--host` is not passed: `HOST` > `BUNQUEUE_HOST` > `BQ_HOST`. ## Complete examples ### Development ```bash # .env.development TCP_PORT=6789 HTTP_PORT=6790 BUNQUEUE_DATA_PATH=./data/dev.db LOG_LEVEL=debug LOG_FORMAT=text ``` ### Production ```bash # .env.production TCP_PORT=6789 HTTP_PORT=6790 BUNQUEUE_DATA_PATH=/var/lib/production.db LOG_LEVEL=info LOG_FORMAT=json AUTH_TOKENS=prod-token-abc123,prod-token-xyz789 # S3 Backup S3_BACKUP_ENABLED=1 S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY S3_BUCKET=company-bunqueue-backups S3_REGION=us-east-1 S3_BACKUP_INTERVAL=3600000 S3_BACKUP_RETENTION=30 S3_BACKUP_PREFIX=production/ ``` ### Docker Compose ```yaml services: bunqueue: image: bunqueue:latest ports: - '6789:6789' - '6790:6790' volumes: - bunqueue-data:/data environment: - BUNQUEUE_DATA_PATH=/data/queue.db - LOG_FORMAT=json - AUTH_TOKENS=${AUTH_TOKENS} - S3_BACKUP_ENABLED=1 - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_BUCKET=${S3_BUCKET} - S3_REGION=${S3_REGION} volumes: bunqueue-data: ``` Kubernetes manifests and more deployment recipes are in the [deployment guide](/guide/deployment/). ## Precedence When the same setting comes from several sources: 1. Command-line arguments (highest) 2. Configuration file 3. Environment variables 4. Default values (lowest) ```bash # Command-line wins TCP_PORT=6789 bunqueue start --tcp-port 7000 # Uses port 7000 ``` --- # HTTP REST API Reference: 83 Endpoints + Live Events Complete HTTP REST API reference for bunqueue on port 6790: 83 endpoints, WebSocket and SSE real-time events, Bearer auth, payloads, and examples. URL: https://bunqueue.dev/api/http/
api reference · http

The HTTP API, every endpoint.

The bunqueue HTTP API runs on port 6790 by default, configurable via the HTTP_PORT environment variable. All request and response bodies use JSON (Content-Type: application/json) unless otherwise noted.

**Response contract:** Unless an endpoint explicitly documents another media type or shape, JSON command responses include an `ok` boolean. Successful responses return `"ok": true` with operation-specific data; failures return `"ok": false` with an `"error"` string. `GET /queues/summary` is the intentional JSON exception: it returns the summary array directly. Health probe text, Prometheus output, SSE, and WebSocket frames use their documented formats. ```json // Success { "ok": true, "id": "019ce9d7-6983-7000-946f-48737be2b0f9" } // Error { "ok": false, "error": "Job not found" } ``` --- ## Authentication When `AUTH_TOKENS` is configured, protected endpoints require a Bearer token in the `Authorization` header. Health probes and CORS preflight stay public; `/prometheus` is also public unless metrics authentication is enabled. Multiple tokens are supported, separated by commas. ```bash # Server configuration (env var) AUTH_TOKENS=secret-token-1,secret-token-2 # Or in bunqueue.config.ts: # auth: { tokens: ['secret-token-1', 'secret-token-2'] } # Client usage curl -H "Authorization: Bearer secret-token-1" http://localhost:6790/stats ``` Token comparison uses **constant-time equality** (`crypto.timingSafeEqual` equivalent) to prevent timing attacks. Each token is compared against all configured tokens, ensuring no information leaks about token length or prefix. **Endpoints that skip authentication:** | Endpoint | Reason | | --------------------------- | ------------------------------------------------------------- | | `GET /health` | Load balancer health checks must work without credentials | | `GET /healthz`, `GET /live` | Kubernetes liveness probes | | `GET /ready` | Kubernetes readiness probes | | `GET /prometheus` | Public by default; protected when `METRICS_AUTH=true` | | `OPTIONS *` | CORS preflight must respond before auth headers are available | The `GET /prometheus` endpoint optionally requires auth when `requireAuthForMetrics: true` is set in the server configuration. This allows Prometheus to scrape without credentials in trusted networks, while requiring auth in public-facing deployments. **Unauthorized response** (`401`): ```json { "ok": false, "error": "Unauthorized" } ``` --- ## CORS Cross-Origin Resource Sharing is configured via the `CORS_ALLOW_ORIGIN` environment variable. By default it is unset, meaning no cross-origin access is granted. Set it explicitly, and only to the origins that need browser access (e.g., `CORS_ALLOW_ORIGIN=https://dashboard.example.com`). See the [Security guide](/security/) for hardening recommendations. When CORS is configured, all JSON responses include the `Access-Control-Allow-Origin` header. Preflight (`OPTIONS`) requests return: ``` HTTP/1.1 204 No Content Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 86400 ``` The `Max-Age: 86400` (24 hours) means browsers cache the preflight response, avoiding repeated OPTIONS requests. --- ## Error Responses All errors follow a consistent format with appropriate HTTP status codes: | Code | Meaning | When | | ----- | -------------- | ------------------------------------------------------------------------------------------------------------ | | `200` | Success | Operation completed successfully | | `400` | Bad Request | Invalid JSON, missing required fields, validation failure (e.g., queue name too long, priority out of range) | | `401` | Unauthorized | Missing or invalid Bearer token | | `404` | Not Found | Job, queue, cron, or webhook not found | | `429` | Rate Limited | Client exceeded the configured request rate | | `500` | Internal Error | Unexpected server error (logged server-side) | **Error response body:** ```json { "ok": false, "error": "Queue name contains invalid characters" } ``` **Validation rules applied to all endpoints:** - **Queue names**: 1-256 characters, alphanumeric + `-_.:` - **Numeric fields**: Validated for type, range, and finiteness (e.g., `delay` must be 0 to 365 days, `priority` must be -1M to +1M) - **Job data**: Max 10MB per job payload - **Job IDs**: UUID v7 format (auto-generated) or custom string (via `jobId` field) --- ## Rate Limiting HTTP requests are rate-limited per client IP using a **sliding window** algorithm. The client IP is resolved in order: `X-Forwarded-For` header (first IP) > `X-Real-IP` header > `"unknown"`. | Variable | Default | Description | | ------------------------- | ------- | --------------------------------------------------- | | `RATE_LIMIT_WINDOW_MS` | `60000` | Sliding window duration in milliseconds | | `RATE_LIMIT_MAX_REQUESTS` | `10000` | Maximum requests per window per IP. | | `RATE_LIMIT_CLEANUP_MS` | `60000` | Interval for cleaning up expired rate limit entries | When rate limited, the server responds with: ```json { "ok": false, "error": "Rate limit exceeded" } ``` Status code: `429`. The client should implement exponential backoff before retrying. :::note This is HTTP-level rate limiting per client IP. For per-queue job throughput limiting, use the [Queue Rate Limit](#set-rate-limit) endpoints. ::: --- ## Job Lifecycle Understanding the job lifecycle is essential for using the API effectively. A job flows through these states:
Job lifecyclestates and transitions
push priority = 0
waiting
push priority > 0
prioritized
push delay > 0
delayed
waiting / prioritized delay expires
↓ pull
active
completed ack
failed fail (terminal)
waiting / prioritized retry
flow dependencies
active
waiting-children
waiting all children complete
**States:** - **waiting**, Job is queued with priority = 0 - **prioritized**, Job is queued with priority > 0 (processed before waiting jobs) - **delayed**, Job waiting for its delay to expire, then moves to waiting/prioritized - **active**, Job is being processed by a worker - **completed**, Job finished successfully - **failed**, Job failed after all retries (stored in DLQ with attempt history) - **waiting-children**, Parent job waiting for child flow jobs to complete **Delayed jobs:** When `delay > 0` is set at push time, the job enters `delayed` state and becomes `waiting` (or `prioritized` if priority > 0) after the delay expires. A delayed job can be promoted immediately via the Promote endpoint. **Durable mode:** In SQLite mode, `durable: true` writes the job synchronously before returning. Without it, jobs use the 10ms in-memory write buffer for higher throughput, with a small window of potential data loss on a hard crash. PostgreSQL admissions are transactional regardless of this flag and never use the SQLite buffer. --- ## Jobs ### Push a Job Add a new job to a queue. The job enters `waiting` state (or `delayed` if `delay > 0`). ``` POST /queues/:queue/jobs ``` ```bash curl -X POST http://localhost:6790/queues/emails/jobs \ -H "Content-Type: application/json" \ -d '{ "data": {"to": "user@test.com", "subject": "Welcome"}, "priority": 10, "delay": 5000 }' ``` **Request body**, only `data` is required: | Field | Type | Default | Description | | ------------------ | -------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `any` | _(required)_ | Job payload. Any JSON-serializable value. Max 10MB. | | `priority` | `number` | `0` | Higher value = processed sooner. Range: -1,000,000 to 1,000,000. | | `delay` | `number` | `0` | Milliseconds before the job becomes available for processing. Max: 1 year. | | `maxAttempts` | `number` | `3` | Maximum retry attempts before the job moves to the DLQ. Range: 1-1000. `attempts` is accepted as an alias. | | `backoff` | `number` or `object` | `1000` | Base retry delay in milliseconds (exponential: `backoff * 2^attempt`, max: 1 day). Also accepts `{ "type": "fixed" \| "exponential", "delay": ms }`. | | `ttl` | `number` | - | Time-to-live from creation in milliseconds. Job is discarded if not processed within this window. Max: 1 year. | | `timeout` | `number` | - | Processing timeout in milliseconds. The broker fails the active attempt at its absolute deadline; a later outcome from that lease generation is ignored. Max: 1 day. | | `uniqueKey` | `string` | - | Deduplication key. If a job with the same `uniqueKey` already exists in the queue, the push is silently ignored. | | `jobId` | `string` | - | Broker-wide custom job ID. If a live job with this ID already exists in any queue, the push is idempotent and returns the existing ID. | | `tags` | `string[]` | `[]` | Metadata tags for filtering and querying. | | `groupId` | `string` | - | Job-group identifier. Claims use ascending group priority with FIFO ties and round-robin across groups; execution is concurrent unless a Worker supplies a group concurrency cap. | | `lifo` | `boolean` | `false` | Last-in-first-out ordering. When true, the job is processed before other jobs at the same priority. | | `removeOnComplete` | `boolean` | `false` | Automatically remove the job from memory after completion. Saves memory for fire-and-forget jobs. | | `removeOnFail` | `boolean` | `false` | Automatically remove the job after final failure (after all retries exhausted). | | `durable` | `boolean` | `false` | SQLite: bypass the write buffer and commit before returning (slower, but no 10 ms buffer-loss window). PostgreSQL admissions are already transactional and do not use the SQLite buffer. | | `dependsOn` | `string[]` | `[]` | Job IDs that must complete before this job becomes available. The job enters `waiting-children` state until all dependencies are met. | | `repeat` | `object` | - | Repeat configuration: `{ every: ms, limit: n }` for interval-based, or `{ pattern: "cron expression" }` for cron-based (optional `tz`, `startDate`, `endDate`, `immediately`). | **Success response** (`200`): ```json { "ok": true, "id": "019ce9d7-6983-7000-946f-48737be2b0f9" } ``` The `id` is a UUID v7 (time-ordered, sortable). If `jobId` was provided and a live job with that broker-wide ID already exists, including in another queue, the existing job's ID is returned (idempotent). **Error responses:** | Status | Error | Cause | | ------ | ---------------------------------------- | -------------------------------------------- | | `400` | `Invalid JSON body` | Request body is not valid JSON | | `400` | `Queue name is required` | Empty queue name | | `400` | `Queue name contains invalid characters` | Queue name has chars outside `a-zA-Z0-9_-.:` | | `400` | `Job data too large (max 10MB)` | Serialized data exceeds 10MB | | `400` | `priority must be an integer` | Non-integer priority | | `400` | `delay must be at least 0` | Negative delay | --- ### Push Jobs in Bulk Push multiple jobs to a queue in a single round-trip. More efficient than individual pushes, all jobs are inserted in a single batch operation. ``` POST /queues/:queue/jobs/bulk ``` ```bash curl -X POST http://localhost:6790/queues/emails/jobs/bulk \ -H "Content-Type: application/json" \ -d '{ "jobs": [ {"data": {"to": "user1@test.com"}, "priority": 5}, {"data": {"to": "user2@test.com"}}, {"data": {"to": "user3@test.com"}, "delay": 60000} ] }' ``` Each item in `jobs` supports all the same fields as a single push and is validated with the same rules (option bounds and `dependsOn` existence; a `dependsOn` entry may also reference the `customId` of an earlier job in the same batch). The operation is **atomic**, either all jobs are pushed or none are: if validation fails for any job, the whole batch is rejected with an error naming the offending index (`jobs[i]: ...`). **Response** (`200`): ```json { "ok": true, "ids": ["id-1", "id-2", "id-3"] } ``` IDs are returned in the same order as the input jobs. --- ### Pull a Job Pull the next available job from a queue for processing. The job transitions from `waiting` to `active` state. Respects priority ordering (higher priority first) and FIFO within the same priority. ``` GET /queues/:queue/jobs[?timeout=ms] ``` ```bash # Immediate return (no wait), returns null if queue is empty curl http://localhost:6790/queues/emails/jobs # Long-poll for up to 5 seconds, waits for a job to become available curl http://localhost:6790/queues/emails/jobs?timeout=5000 ``` | Parameter | Type | Default | Max | Description | | --------- | -------- | ------- | ------- | ---------------------------------------------------------------------- | | `timeout` | `number` | `0` | `60000` | Long-poll timeout in ms. `0` = return immediately if no job available. | **Response with job** (`200`): ```json { "ok": true, "job": { "id": "019ce9d7-6983-7000-946f-48737be2b0f9", "queue": "emails", "data": { "to": "user@test.com", "subject": "Welcome" }, "priority": 10, "createdAt": 1700000000000, "runAt": 1700000000000, "attempts": 0, "maxAttempts": 3, "backoff": 1000, "progress": 0, "tags": [], "lifo": false, "removeOnComplete": false, "removeOnFail": false } } ``` **No job available** (`200`): ```json { "ok": true, "job": null } ``` **Behavior notes:** - Paused queues return `null` even if jobs exist - The pulled job is tracked for the duration of the HTTP request. If the client disconnects without ACKing, the stall detector will eventually return the job to `waiting` state - Rate-limited queues may return `null` even if jobs exist (rate limit exceeded) - Per-group concurrency: if the job's `groupId` has reached its concurrency limit, the next job from a different group is returned --- ### Pull Jobs in Batch Pull multiple jobs at once. More efficient than individual pulls for high-throughput workers. ``` POST /queues/:queue/jobs/pull-batch ``` ```bash curl -X POST http://localhost:6790/queues/emails/jobs/pull-batch \ -H "Content-Type: application/json" \ -d '{"count": 10, "timeout": 5000}' ``` | Field | Type | Required | Range | Description | | --------- | -------- | -------- | ------- | ---------------------------------------------------------------------------------------- | | `count` | `number` | Yes | 1-1000 | Number of jobs to pull | | `timeout` | `number` | No | 0-60000 | Long-poll timeout (ms), honored with or without `owner`. Default 0 (return immediately). | | `owner` | `string` | No | - | Lock owner identifier for lock-based processing | | `lockTtl` | `number` | No | - | Lock time-to-live (ms). Job is released if lock expires without ACK. | **Response** (`200`): ```json { "ok": true, "jobs": [ {"id": "id-1", "queue": "emails", "data": {...}, "priority": 5, ...}, {"id": "id-2", "queue": "emails", "data": {...}, "priority": 3, ...} ] } ``` Returns fewer jobs than `count` if the queue doesn't have enough available jobs. --- ### Get a Job Retrieve a job by ID. Returns the full job object regardless of state (waiting, active, delayed, completed). ``` GET /jobs/:id ``` ```bash curl http://localhost:6790/jobs/019ce9d7-6983-7000-946f-48737be2b0f9 ``` **Response** (`200`): ```json { "ok": true, "job": { "id": "019ce9d7-6983-7000-946f-48737be2b0f9", "queue": "emails", "data": { "to": "user@test.com" }, "priority": 0, "createdAt": 1700000000000, "runAt": 1700000000000, "startedAt": 1700000001000, "completedAt": null, "attempts": 1, "maxAttempts": 3, "backoff": 1000, "progress": 50, "tags": ["onboarding"], "lifo": false, "removeOnComplete": false, "removeOnFail": false } } ``` **Not found** (`404`): `{ "ok": false, "error": "Job not found" }` :::note Jobs with `removeOnComplete: true` or `removeOnFail: true` are permanently deleted after completion/failure and cannot be retrieved. ::: --- ### Get Job by Custom ID Look up a job using the custom `jobId` that was set at push time. Useful for idempotent workflows where you generate your own IDs. ``` GET /jobs/custom/:customId ``` ```bash curl http://localhost:6790/jobs/custom/order-12345 ``` Returns the same response format as `GET /jobs/:id`. --- ### Get Job State ``` GET /jobs/:id/state ``` ```json { "ok": true, "id": "019ce9d7-...", "state": "active" } ``` Possible states: `waiting`, `prioritized`, `delayed`, `active`, `waiting-children`, `completed`, `failed`, `unknown` (job not found) --- ### Get Job Result Retrieve the result stored when a job was acknowledged. Only available for completed jobs. ``` GET /jobs/:id/result ``` ```json { "ok": true, "id": "019ce9d7-...", "result": { "sent": true, "messageId": "abc-123" } } ``` Results are stored in an LRU cache (max 5,000 entries). Oldest results are evicted when the cache is full. For permanent result storage, use the `result` field in your own database. --- ### Cancel a Job Remove a job from the queue. Works on `waiting`, `delayed`, and `active` jobs. ``` DELETE /jobs/:id ``` ```bash curl -X DELETE http://localhost:6790/jobs/019ce9d7-... ``` **Response** (`200`): `{ "ok": true }` If the job is `active`, it's removed from the processing queue and the worker's next heartbeat or ACK attempt will fail with "job not found". The job is not re-queued. --- ### Acknowledge a Job Mark a job as successfully completed. The job transitions from `active` to `completed` state. Optionally store a result that can be retrieved later via `GET /jobs/:id/result`. ``` POST /jobs/:id/ack ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../ack \ -H "Content-Type: application/json" \ -d '{"result": {"sent": true, "messageId": "abc-123"}}' ``` **Request body** (optional): | Field | Type | Description | | -------- | -------- | --------------------------------------------------- | | `result` | `any` | Completion result. Stored in LRU cache (5,000 max). | | `token` | `string` | Lock token (if using lock-based processing). | **Response** (`200`): `{ "ok": true }` **Error** (`400`): `{ "ok": false, "error": "Job not found or not active" }` **What happens on ACK:** 1. Job is removed from the `active` processing queue 2. Result is stored in the LRU cache (if provided) 3. Completion counter incremented 4. `job:completed` event broadcast to all subscribers 5. `queue:counts` event broadcast with updated counts 6. Dependent jobs (via `dependsOn`) are checked and promoted if all dependencies are met 7. If `removeOnComplete: true`, the job is permanently deleted from memory --- ### Acknowledge Jobs in Batch Acknowledge multiple jobs in a single round-trip. ``` POST /jobs/ack-batch ``` ```bash curl -X POST http://localhost:6790/jobs/ack-batch \ -H "Content-Type: application/json" \ -d '{"ids": ["id-1", "id-2", "id-3"], "results": [{"a": 1}, null, {"c": 3}]}' ``` | Field | Type | Required | Description | | --------- | ----------- | -------- | ------------------------------------------------- | | `ids` | `string[]` | Yes | Job IDs to acknowledge | | `results` | `unknown[]` | No | Per-job results (positional, same order as `ids`) | | `tokens` | `string[]` | No | Lock tokens (positional) | --- ### Fail a Job Mark a job as failed. If retry attempts remain, the job is automatically re-queued with exponential backoff (`backoff * 2^attempt`). If all attempts are exhausted, the job moves to the Dead Letter Queue (DLQ). ``` POST /jobs/:id/fail ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../fail \ -H "Content-Type: application/json" \ -d '{"error": "SMTP connection refused"}' ``` | Field | Type | Description | | ------- | -------- | ------------------------------------------------- | | `error` | `string` | Error message. Stored with the job for debugging. | | `token` | `string` | Lock token (if using lock-based processing). | **Retry behavior:**
Retry behaviorexponential backoff
Attempt 1 fails
wait 1s backoff
retry
Attempt 2 fails
wait 2s backoff * 2
retry
Attempt 3 fails
wait 4s backoff * 4
move to DLQ
The retry delay is calculated as `min(backoff * 2^attempt, 24 hours)`. --- ### Update Job Data Edit the JSON payload of a job in-place. Works on jobs in `waiting`, `delayed`, or `active` state. Useful for modifying job parameters before processing or while a job is being retried. ``` PUT /jobs/:id/data ``` ```bash curl -X PUT http://localhost:6790/jobs/019ce9d7-.../data \ -H "Content-Type: application/json" \ -d '{"data": {"to": "new@email.com", "subject": "Updated subject"}}' ``` The entire `data` field is replaced (not merged). To update a single field, read the current data first, modify it, then PUT the full object. **Broadcasts:** `job:data-updated` event. --- ### Change Job Priority Change the priority of a job in `waiting` or `delayed` state. Higher priority = processed sooner. ``` PUT /jobs/:id/priority ``` ```json { "priority": 100 } ``` The job is repositioned in the priority queue immediately. Does not work on `active` jobs (they're already being processed). **Broadcasts:** `job:priority-changed` event with `{ jobId, newPriority }`. --- ### Promote a Delayed Job Move a job from `delayed` to `waiting` state for immediate processing. The job becomes available for the next `PULL` operation. ``` POST /jobs/:id/promote ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../promote ``` **Error** (`400`): `{ "ok": false, "error": "Job not found or not delayed" }`, returned if the job doesn't exist, is already in `waiting` state, or is `active`. **Broadcasts:** `job:promoted` event. --- ### Move to Waiting Alias for Promote. Identical behavior. ``` POST /jobs/:id/move-to-wait ``` --- ### Move to Delayed Move an `active` job back to `delayed` state. Useful when a worker determines it can't process the job right now but doesn't want to fail it. ``` POST /jobs/:id/move-to-delayed ``` ```json { "delay": 60000 } ``` The job will become `waiting` again after `delay` milliseconds. --- ### Change Delay Update the delay of a `delayed` job. The job's `runAt` time is recalculated. ``` PUT /jobs/:id/delay ``` ```json { "delay": 30000 } ``` **Broadcasts:** `job:delay-changed` event with `{ jobId, newDelay }`. --- ### Discard to DLQ Move a job directly to the Dead Letter Queue, bypassing the normal retry mechanism. Works on `waiting`, `delayed`, and `active` jobs. ``` POST /jobs/:id/discard ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../discard ``` **Broadcasts:** `job:discarded` event. --- ### Wait for Job Completion Long-poll until a job completes or the timeout expires. This is **event-driven** (not polling), the server subscribes to the job's completion event internally and resolves immediately when the job finishes. ``` POST /jobs/:id/wait ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../wait \ -H "Content-Type: application/json" \ -d '{"timeout": 30000}' ``` | Field | Type | Default | Description | | --------- | -------- | ------- | ----------------------------------------------- | | `timeout` | `number` | `30000` | Maximum wait time in milliseconds (max: 600000) | **Completed within timeout:** ```json { "ok": true, "completed": true, "result": { "sent": true } } ``` **Timed out:** ```json { "ok": true, "completed": false } ``` **Not found:** ```json { "ok": false, "error": "Job not found" } ``` If the job is already completed when the request arrives, the result is returned immediately without waiting. --- ### Get/Update Job Progress Workers can report progress (0-100) during long-running jobs. The dashboard can display this as a progress bar. **Get current progress:** ``` GET /jobs/:id/progress ``` ```json { "ok": true, "progress": 75, "message": "Processing attachments..." } ``` **Update progress:** ``` POST /jobs/:id/progress ``` ```json { "progress": 75, "message": "Processing attachments..." } ``` Progress is stored on the job object and broadcast as a `job:progress` event to all WebSocket subscribers. --- ### Get Children Values For jobs that use `dependsOn` (flow/pipeline), retrieve the results of all completed child jobs. ``` GET /jobs/:id/children ``` ```json { "ok": true, "data": { "values": { "child-job-1": { "result": "..." }, "child-job-2": { "result": "..." } } } } ``` --- ### Job Heartbeat Send a heartbeat to prevent the stall detector from marking the job as stalled. Workers should send heartbeats at regular intervals (default: every 10 seconds) for long-running jobs. ``` POST /jobs/:id/heartbeat ``` ```json { "token": "lock-token", "duration": 30000 } ``` Both fields are optional. If the job doesn't exist or isn't active, returns an error. **Batch heartbeat:** ``` POST /jobs/heartbeat-batch ``` ```json { "ids": ["id-1", "id-2"], "tokens": ["tok-1", "tok-2"] } ``` --- ### Extend Lock Extend the lock TTL on an active job. Used in lock-based processing where a worker holds a lock on a job and needs more time. ``` POST /jobs/:id/extend-lock ``` ```json { "duration": 30000, "token": "lock-token" } ``` **Batch extend:** ``` POST /jobs/extend-locks ``` ```json { "ids": ["id-1", "id-2"], "tokens": ["tok-1", "tok-2"], "durations": [30000, 60000] } ``` --- ### Job Logs Structured logging attached to individual jobs. Useful for debugging failed jobs, each log entry has a level and message. **Add a log entry:** ``` POST /jobs/:id/logs ``` ```bash curl -X POST http://localhost:6790/jobs/019ce9d7-.../logs \ -H "Content-Type: application/json" \ -d '{"message": "Connecting to SMTP server...", "level": "info"}' ``` | Field | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------ | | `message` | `string` | Yes | Log message | | `level` | `string` | No | `info` (default), `warn`, or `error` | Logs are stored in an LRU cache (max 100 entries per job, 10,000 jobs total). **Get all logs:** ``` GET /jobs/:id/logs ``` **Clear logs:** ``` DELETE /jobs/:id/logs ``` --- ## Queues ### List All Queues Returns all queue names that have had at least one job pushed to them. Queue names persist until the queue is obliterated. ``` GET /queues ``` ```json { "ok": true, "queues": ["emails", "notifications", "reports"] } ``` --- ### Queues Summary All queues with paused state and per-state counts in a single call (one round-trip instead of N). ``` GET /queues/summary ``` ```json [ { "name": "emails", "paused": false, "counts": { "waiting": 125, "prioritized": 7, "active": 5, "completed": 10234, "failed": 23, "delayed": 2 } } ] ``` Note: this endpoint returns a bare JSON array (no `ok` wrapper). --- ### List Workers for a Queue Workers currently registered for a specific queue. ``` GET /queues/:queue/workers ``` ```json { "ok": true, "workers": [ { "id": "w-1", "name": "email-worker", "queues": ["emails"], "concurrency": 5, "registeredAt": 1700000000000, "lastSeen": 1700000010000, "activeJobs": 3, "processedJobs": 1500, "failedJobs": 12 } ] } ``` --- ### List Jobs by State Paginated listing of jobs in a specific queue, filtered by state. ``` GET /queues/:queue/jobs/list[?status=waiting&limit=10&offset=0] ``` ```bash curl "http://localhost:6790/queues/emails/jobs/list?status=waiting&limit=20&offset=0" # multiple states (comma-separated or repeated): curl "http://localhost:6790/queues/emails/jobs/list?status=failed,completed" ``` | Parameter | Type | Default | Description | | --------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `string` | all | State filter: `waiting`, `prioritized`, `delayed`, `active`, `completed`, `failed`, `waiting-children`. Aliases: `state`, `states`. Repeatable and comma-separated for multiple states. | | `limit` | `number` | all | Max jobs to return | | `offset` | `number` | `0` | Skip first N jobs | **Response** (`200`): ```json { "ok": true, "jobs": [ {"id": "...", "queue": "emails", "data": {...}, "priority": 5, "createdAt": 1700000000000, "runAt": 1700000000000, "attempts": 0, "progress": 0} ] } ``` Jobs are ordered by `createdAt` ascending (oldest first), with the job ID as a deterministic tie-breaker. State filtering happens before `offset` and `limit`, so pages are stable even when `createdAt` values match. --- ### Get Job Counts Returns the number of jobs in each state for a specific queue. ``` GET /queues/:queue/counts ``` ```json { "ok": true, "counts": { "waiting": 150, "prioritized": 0, "active": 12, "delayed": 30, "completed": 5000, "failed": 3, "waiting-children": 4, "paused": 0 } } ``` :::tip For real-time count updates without polling, subscribe to `queue:counts` via [WebSocket pub/sub](#websocket-pubsub). A count refresh is scheduled after job lifecycle events; updates for the same queue within 10ms are coalesced into one latest-value event. ::: --- ### Get Total Count Returns the total number of jobs (all states) in a queue. ``` GET /queues/:queue/count ``` ```json { "ok": true, "count": 192 } ``` --- ### Get Counts per Priority Returns a breakdown of jobs by priority level. Useful for dashboards showing priority distribution. ``` GET /queues/:queue/priority-counts ``` ```json { "ok": true, "queue": "emails", "counts": { "0": 100, "5": 30, "10": 12 } } ``` --- ### Check If Paused ``` GET /queues/:queue/paused ``` ```json { "ok": true, "paused": false } ``` --- ### Pause a Queue Stop processing new jobs from this queue. Active jobs continue to completion, only new pulls are blocked. ``` POST /queues/:queue/pause ``` **Broadcasts:** `queue:paused` event. --- ### Resume a Queue Resume processing after a pause. ``` POST /queues/:queue/resume ``` **Broadcasts:** `queue:resumed` event. --- ### Drain a Queue Remove **all** `waiting` and `delayed` jobs from a queue. Active jobs are not affected, they continue processing normally. This is useful for clearing a backlog without affecting in-progress work. ``` POST /queues/:queue/drain ``` ```json { "ok": true, "count": 150 } ``` **Broadcasts:** `queue:drained` event with `{ queue, count }`. --- ### Obliterate a Queue Completely destroy a queue and all its jobs (waiting, delayed, and metadata). Active jobs continue but their ACK/FAIL will be no-ops. ``` POST /queues/:queue/obliterate ``` :::caution This is **irreversible**. All jobs in the queue are permanently deleted. The queue name is removed from the queue list. ::: **Broadcasts:** `queue:obliterated` event. --- ### Clean a Queue Remove jobs older than a grace period, optionally filtered by state. Useful for maintenance, cleaning up old waiting/delayed jobs that are no longer relevant. ``` POST /queues/:queue/clean ``` ```bash curl -X POST http://localhost:6790/queues/emails/clean \ -H "Content-Type: application/json" \ -d '{"grace": 86400000, "state": "waiting", "limit": 500}' ``` | Field | Type | Default | Description | | ------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------- | | `grace` | `number` | `0` | Only remove jobs older than this many milliseconds. `0` = remove all. | | `state` | `string` | queued | `waiting`/`delayed`/`prioritized`/`paused` (all clean the queued set, the default), `completed`, or `failed`. | | `limit` | `number` | `1000` | Max jobs to remove per call. | **Response** (`200`): ```json { "ok": true, "count": 42, "ids": ["019ce9d7-...", "..."] } ``` Uses a temporal index for efficient O(log n + k) cleanup instead of full queue scan. **Broadcasts:** `queue:cleaned` event with `{ queue, state, count }`. --- ### Promote All Delayed Jobs Move all (or up to N) delayed jobs in a queue to `waiting` state immediately. ``` POST /queues/:queue/promote-jobs ``` ```json { "count": 50 } ``` Omit `count` to promote all delayed jobs. --- ### Retry Completed Jobs Re-queue completed jobs for reprocessing. Useful for replaying jobs after a bug fix. ``` POST /queues/:queue/retry-completed ``` ```json { "id": "specific-job-id" } ``` Omit `id` to retry all completed jobs in the queue. --- ## Dead Letter Queue (DLQ) Jobs that exhaust all retry attempts or are explicitly discarded land in the DLQ. Each queue has its own DLQ. DLQ entries include the original job data, failure reason, and timestamp. ### List DLQ Jobs ``` GET /queues/:queue/dlq[?limit=100&offset=0] ``` | Parameter | Type | Default | Description | | --------- | -------- | ------- | --------------------- | | `limit` | `number` | all | Max entries to return | | `offset` | `number` | `0` | Skip first N entries | Returns full DLQ entries (original job + failure metadata) under `entries`, plus the `total` count for pagination: ```json { "ok": true, "entries": [ { "job": { "id": "...", "data": {}, "attempts": 3 }, "enteredAt": 1700000000000, "reason": "max_attempts_exceeded", "error": "SMTP timeout", "attempts": [ { "attempt": 1, "startedAt": 1700000000000, "failedAt": 1700000001000, "reason": "explicit_fail", "error": "SMTP timeout", "duration": 1000 } ], "retryCount": 0 } ], "total": 1 } ``` Omit `limit`/`offset` to return all entries. --- ### DLQ Stats Aggregated DLQ statistics for a queue. ``` GET /queues/:queue/dlq/stats ``` ```json { "ok": true, "stats": { "total": 12, "byReason": { "explicit_fail": 4, "max_attempts_exceeded": 6, "timeout": 1, "stalled": 1, "ttl_expired": 0, "worker_lost": 0, "unknown": 0 }, "byQueue": { "emails": 12 }, "pendingRetry": 0, "expired": 0, "oldestEntry": 1700000000000, "newestEntry": 1700003600000 } } ``` --- ### Retry DLQ Jobs Re-queue jobs from the DLQ back to the main queue for reprocessing. The job's attempt counter is reset. ``` POST /queues/:queue/dlq/retry ``` ```json { "jobId": "specific-job-id" } ``` Omit `jobId` to retry **all** DLQ jobs. Returns `{ "ok": true, "count": 5 }`. **Broadcasts:** `dlq:retried` (single) or `dlq:retry-all` (all) event. --- ### Purge DLQ Remove all jobs from the DLQ permanently. This is irreversible. ``` POST /queues/:queue/dlq/purge ``` ```json { "ok": true, "count": 12 } ``` **Broadcasts:** `dlq:purged` event with `{ queue, count }`. --- ## Rate Limiting & Concurrency Per-queue controls for throughput and parallelism. These are queue-level settings, independent of HTTP rate limiting. ### Set Rate Limit Limit the number of jobs that can be processed from a queue: `limit` jobs per `duration` ms (default 1000, so jobs per second). ``` PUT /queues/:queue/rate-limit ``` ```json { "limit": 100, "duration": 60000, "ttl": 30000 } ``` `duration` and `ttl` are optional. `duration` sets the window in ms; `ttl` makes the limit temporary, the server clears it by itself after that many ms. Invalid values fall back to the defaults (1 second window, permanent limit). When the rate limit is hit, workers pulling from this queue receive `null` until the next window opens. **Broadcasts:** `ratelimit:set` event. ### Clear Rate Limit ``` DELETE /queues/:queue/rate-limit ``` **Broadcasts:** `ratelimit:cleared` event. ### Set Concurrency Limit Limit the number of jobs that can be processed simultaneously from a queue. ``` PUT /queues/:queue/concurrency ``` ```json { "concurrency": 5 } ``` Accepts either `concurrency` (natural for this endpoint) or `limit`. A non-numeric value is rejected. **Broadcasts:** `concurrency:set` event. ### Clear Concurrency Limit ``` DELETE /queues/:queue/concurrency ``` **Broadcasts:** `concurrency:cleared` event. --- ## Queue Configuration ### Stall Detection Stall detection identifies jobs that a worker started processing but never acknowledged. This can happen when a worker crashes, hangs, or loses network connectivity. **Get current config:** ``` GET /queues/:queue/stall-config ``` **Update config:** ``` PUT /queues/:queue/stall-config ``` ```json { "config": { "stallInterval": 30000, "maxStalls": 3, "gracePeriod": 5000 } } ``` | Field | Default | Description | | --------------- | ------- | ------------------------------------------------------------- | | `stallInterval` | `30000` | How often to check for stalled jobs (ms) | | `maxStalls` | `3` | Max times a job can stall before moving to DLQ | | `gracePeriod` | `5000` | Grace period after job starts before stall detection kicks in | **Broadcasts:** `config:stall-changed` event. ### DLQ Configuration **Get current config:** ``` GET /queues/:queue/dlq-config ``` **Update config:** ``` PUT /queues/:queue/dlq-config ``` ```json { "config": { "autoRetry": true, "maxAge": 604800000, "maxEntries": 10000 } } ``` | Field | Default | Description | | ------------ | ----------- | -------------------------------------------------------------------------- | | `autoRetry` | `false` | Automatically retry DLQ entries after a delay | | `maxAge` | `604800000` | Max age of DLQ entries in ms (default: 7 days). Older entries are removed. | | `maxEntries` | `10000` | Max DLQ entries per queue. Oldest are evicted when full. | **Broadcasts:** `config:dlq-changed` event. --- ## Cron Jobs Schedule recurring jobs using cron expressions or fixed intervals. ### List All Crons ``` GET /crons ``` ```json { "ok": true, "crons": [ { "name": "daily-cleanup", "queue": "maintenance", "schedule": "0 2 * * *", "repeatEvery": null, "nextRun": 1700100000000, "executions": 42, "maxLimit": null, "timezone": "UTC" } ] } ``` --- ### Add a Cron Job ``` POST /crons ``` ```bash curl -X POST http://localhost:6790/crons \ -H "Content-Type: application/json" \ -d '{ "name": "daily-cleanup", "queue": "maintenance", "data": {"task": "cleanup-stale-sessions"}, "schedule": "0 2 * * *", "timezone": "America/New_York" }' ``` | Field | Type | Required | Description | | ---------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Yes | Unique identifier. Re-using a name updates the existing cron. | | `queue` | `string` | Yes | Target queue for the generated jobs. | | `data` | `any` | Yes | Job payload pushed on each execution. | | `schedule` | `string` | * | Cron expression (`"*/5 * * * *"`, `"0 2 * * *"`). | | `repeatEvery` | `number` | * | Positive safe-integer interval in ms (alternative to cron expression). | | `timezone` | `string` | No | IANA timezone. Raw HTTP uses the server/system timezone when omitted. | | `priority` | `number` | No | Priority for generated jobs. | | `maxLimit` | `number` | No | Max total executions. Cron is removed after reaching this count. | | `immediately` | `boolean` | No | Fire once on creation, then continue on schedule (default `false`). | | `skipIfNoWorker` | `boolean` | No | Skip a tick when no worker is registered for the queue (default `false`). | | `preventOverlap` | `boolean` | No | Deduplicate overlapping runs, a tick is skipped while the previous generated job is still pending/active (default `true`). | | `jobOptions` | `object` | No | Per-job options applied to every generated job: `maxAttempts`, `backoff`, `timeout`, `delay`, `stallTimeout`, `removeOnComplete`, `removeOnFail`. | \* At least one of `schedule` or `repeatEvery` is required. When both are valid, `schedule` takes precedence for backward compatibility. **Broadcasts:** `cron:created` event. --- ### Get a Cron Job ``` GET /crons/:name ``` --- ### Delete a Cron Job ``` DELETE /crons/:name ``` **Broadcasts:** `cron:deleted` event. --- ## Webhooks Register HTTP endpoints to be called when specific job events occur. Webhooks are delivered with exponential backoff on failure (3 retries, 1s base delay). ### List All Webhooks ``` GET /webhooks ``` --- ### Add a Webhook ``` POST /webhooks ``` ```bash curl -X POST http://localhost:6790/webhooks \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/bunqueue", "events": ["completed", "failed"], "queue": "emails", "secret": "whsec_abc123" }' ``` | Field | Type | Required | Description | | -------- | ---------- | -------- | -------------------------------------------------------------------------------------------- | | `url` | `string` | Yes | HTTPS endpoint URL. Validated against SSRF (localhost, private IPs, cloud metadata blocked). | | `events` | `string[]` | Yes | Event types to subscribe to (`completed`, `failed`, `pushed`, `started`). | | `queue` | `string` | No | Filter to specific queue. Omit for all queues. | | `secret` | `string` | No | HMAC signing secret for verifying webhook authenticity. | **Response** (`200`): ```json { "ok": true, "data": { "webhookId": "wh-abc123", "url": "https://...", "events": ["completed", "failed"], "createdAt": 1700000000000 } } ``` **Broadcasts:** `webhook:added` event. --- ### Remove a Webhook ``` DELETE /webhooks/:id ``` **Broadcasts:** `webhook:removed` event. --- ### Enable/Disable a Webhook ``` PUT /webhooks/:id/enabled ``` ```json { "enabled": false } ``` Disabled webhooks stop receiving deliveries but retain their configuration. --- ## Workers ### List All Workers ``` GET /workers ``` ```json { "ok": true, "data": { "workers": [ { "id": "w-1", "name": "email-worker", "queues": ["emails"], "lastSeen": 1700000000000, "activeJobs": 3, "processedJobs": 1500, "failedJobs": 12 } ], "stats": { "total": 4, "active": 3 } } } ``` --- ### Register a Worker ``` POST /workers ``` ```json { "name": "email-worker-1", "queues": ["emails", "notifications"] } ``` **Broadcasts:** `worker:connected` event with `{ workerId, name, queues }`. --- ### Unregister a Worker ``` DELETE /workers/:id ``` **Broadcasts:** `worker:disconnected` event with `{ workerId }`. --- ### Worker Heartbeat Keep a worker's registration alive. Workers that stop sending heartbeats are eventually marked as disconnected. ``` POST /workers/:id/heartbeat ``` --- ## Monitoring ### Health Check Comprehensive health information for load balancers and monitoring systems. No authentication required. ``` GET /health ``` ```json { "ok": true, "status": "healthy", "uptime": 86400, "version": "x.y.z", "queues": { "waiting": 150, "active": 12, "delayed": 30, "completed": 50000, "dlq": 3 }, "connections": { "tcp": 8, "ws": 4, "sse": 2 }, "memory": { "heapUsed": 45, "heapTotal": 64, "rss": 82 } } ``` Memory values in MB. Uptime in seconds. Returns `"status": "degraded"` when disk is full. --- ### Liveness / Readiness Probes ``` GET /healthz # Returns "OK" (text/plain, 200) GET /live # Returns "OK" (text/plain, 200) GET /ready # Returns { "ok": true, "ready": true } ``` No authentication required. Designed for Kubernetes probe configuration. --- ### Ping ``` GET /ping ``` ```json { "ok": true, "data": { "pong": true, "time": 1700000000000 } } ``` --- ### Stats Server statistics with throughput counters, memory usage, and internal collection sizes. ``` GET /stats ``` ```json { "ok": true, "stats": { "waiting": 150, "active": 12, "delayed": 30, "completed": 50000, "dlq": 3, "totalPushed": 100000, "totalPulled": 99500, "totalCompleted": 98000, "totalFailed": 200, "uptime": 86400 }, "memory": { "heapUsed": 45, "heapTotal": 64, "rss": 82, "external": 2, "arrayBuffers": 1 }, "collections": { "jobIndex": 1500, "completedJobs": 5000, "processingTotal": 12, "queuedTotal": 150, "temporalIndexTotal": 30 } } ``` :::tip For real-time stats without polling, subscribe to `stats:snapshot` via [WebSocket pub/sub](#websocket-pubsub). Pushed every 5 seconds. ::: --- ### Metrics (JSON) ``` GET /metrics ``` ```json { "ok": true, "metrics": { "totalPushed": 100000, "totalPulled": 99500, "totalCompleted": 98000, "totalFailed": 200 } } ``` --- ### Prometheus Metrics ``` GET /prometheus ``` Returns `text/plain; version=0.0.4` format for Prometheus scraping. Includes per-queue gauges, throughput counters, and latency histograms. Optionally requires auth (`requireAuthForMetrics`). --- ### Storage Status ``` GET /storage ``` ```json { "ok": true, "data": { "diskFull": false, "error": null, "since": null } } ``` In SQLite mode, `diskFull: true` means durable writes are rejected; existing in-memory work can continue while health remains degraded. In PostgreSQL mode, `diskFull` is not a local-disk signal: `error` and `since` report database or projection degradation, affected queue operations can reject, and `/ready` returns `503` until authority is restored. Memory-only mode has no persistent storage health to report. --- ### Force Garbage Collection ``` POST /gc ``` Triggers Bun GC and internal memory compaction (`compactMemory()`). Returns before/after heap stats in MB. ```json { "ok": true, "before": { "heapUsed": 52, "heapTotal": 64, "rss": 90 }, "after": { "heapUsed": 45, "heapTotal": 64, "rss": 85 } } ``` --- ### Heap Stats ``` GET /heapstats ``` Detailed V8/JSC heap breakdown for debugging memory leaks. Returns top 20 object types by count, internal collection sizes, and heap metrics. --- ## Dashboard Endpoints Aggregated read-only snapshots designed for dashboards (fewer round-trips than composing the individual endpoints). ### Overview ``` GET /dashboard ``` Single call returning `stats` (global counts + totals + uptime), `throughput` (per-second rates), `latency` (averages + percentiles, nested per operation: `push`, `pull`, `ack`), `memory`, `collections`, `workers` (stats + list, capped at 100 with a `truncated` flag), `crons` (total + list, capped at 100), `storage`, and `timestamp`. ### Queues (paginated) ``` GET /dashboard/queues[?limit=100&offset=0] ``` | Parameter | Type | Default | Description | | --------- | -------- | ------- | ---------------------------- | | `limit` | `number` | `100` | Max queues to return (1-500) | | `offset` | `number` | `0` | Skip first N queues | ```json { "ok": true, "queues": [ { "name": "emails", "waiting": 125, "delayed": 2, "active": 5, "dlq": 3, "paused": false } ], "total": 3, "limit": 100, "offset": 0, "timestamp": 1700000000000 } ``` ### Queue Detail ``` GET /dashboard/queues/:queue[?includeJobs=true] ``` Returns `counts` (all 8 states, paused-aware), `paused`, `priorityCounts`, a `dlqPreview` (up to 10 entries), and, with `includeJobs=true`, up to 10 job summaries per state (`waiting`, `active`, `delayed`, `paused`). --- ## Real-time Events bunqueue provides two real-time event channels: **Server-Sent Events (SSE)** for simple one-way streaming, and **WebSocket** with full pub/sub for interactive dashboards. ### Server-Sent Events (SSE) ``` GET /events GET /events/queues/:queue ``` SSE broadcasts all job events in the legacy format (`{ eventType, queue, jobId, ... }`). For authenticated SSE, use `@microsoft/fetch-event-source` (native `EventSource` doesn't support custom headers). ```javascript const events = new EventSource('http://localhost:6790/events'); events.onmessage = (e) => { const data = JSON.parse(e.data); if (data.connected) return; console.log(`[${data.eventType}] ${data.queue} ${data.jobId}`); }; ``` ### WebSocket Pub/Sub ``` ws://localhost:6790/ws ws://localhost:6790/ws/queues/:queue ``` WebSocket supports **86 explicit event names** across 19 namespaces, plus namespace and global wildcards. Clients subscribe to specific events and receive only matching data, **zero polling needed**. #### Event Format Every pub/sub event follows this structure: ```json { "event": "job:completed", "ts": 1710000000000, "data": { "queue": "payments", "jobId": "abc-123" } } ``` - `event`, event name (category:action) - `ts`, unix timestamp in milliseconds - `data`, event-specific payload #### Subscribe / Unsubscribe After connecting, send a `Subscribe` command to start receiving events: ```json { "cmd": "Subscribe", "events": ["job:*", "queue:counts", "stats:snapshot", "health:status"], "reqId": "1" } ``` **Response:** ```json { "ok": true, "subscribed": ["job:*", "queue:counts", "stats:snapshot", "health:status"], "reqId": "1" } ``` **Unsubscribe from specific events:** ```json { "cmd": "Unsubscribe", "events": ["job:progress"] } ``` **Unsubscribe from everything:** ```json { "cmd": "Unsubscribe", "events": [] } ``` #### Wildcards | Pattern | Matches | | --------------- | ------------------------------------------------------------------------ | | `*` | Every emitted event | | `job:*` | All 21 explicit job events, plus compatibility emissions described below | | `queue:*` | All 10 queue events, including `queue:counts` | | `flow:*` | Both flow events | | `worker:*` | All 7 worker events | | `dlq:*` | All 6 DLQ events | | `cron:*` | All 6 cron events | | `stats:*` | `stats:snapshot` | | `health:*` | `health:status` | | `storage:*` | All 5 storage events | | `config:*` | Both config events | | `ratelimit:*` | All 4 rate-limit events | | `concurrency:*` | All 3 concurrency events | | `webhook:*` | All 6 webhook events | | `batch:*` | Both batch events | | `client:*` | Both client events | | `auth:*` | `auth:failed` | | `cleanup:*` | Both cleanup events | | `server:*` | All 4 server events | | `memory:*` | `memory:compacted` | `job:*` and `*` may also receive `job:waiting` and `job:duplicated` from the legacy job-event bridge. They are compatibility emission names, not entries in the explicit subscription allow-list, so subscribing to either exact name is currently rejected. Use `job:deduplicated` for an exact deduplication subscription. #### Legacy Mode Clients that never send `Subscribe` receive all job events in the **old format** (`{ eventType: "completed", queue, jobId, ... }`). This maintains backward compatibility with existing integrations. #### Sending Commands WebSocket clients can also send any TCP protocol command as JSON. This allows a dashboard to both receive events AND send commands (pause queue, retry job, etc.) over a single connection: ```javascript // Send a command ws.send(JSON.stringify({ cmd: 'Pause', queue: 'emails', reqId: '2' })); // Response { "ok": true, "reqId": "2" } ``` #### Authentication Two options: 1. **Header auth:** Send `Authorization: Bearer ` during the WebSocket handshake 2. **Command auth:** Send `{ "cmd": "Auth", "token": "my-secret" }` after connecting #### Connection Cleanup When a WebSocket disconnects, all jobs owned by that client (pulled but not ACKed) are automatically released back to the queue. This prevents jobs from being stuck when a worker disconnects unexpectedly. #### Complete Dashboard Example ```javascript const ws = new WebSocket('ws://localhost:6790/ws'); ws.onopen = () => { // Subscribe to everything a dashboard needs ws.send( JSON.stringify({ cmd: 'Subscribe', events: [ 'job:*', // All job lifecycle events 'queue:counts', // Real-time count updates (eliminates N+1 polling) 'stats:snapshot', // Global stats every 5s 'health:status', // Health check every 10s 'worker:*', // Worker connect/disconnect 'dlq:*', // DLQ events 'cron:*', // Cron events 'queue:paused', // Queue state changes 'queue:resumed', ], }) ); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); // Pub/sub event if (msg.event) { switch (msg.event) { // Periodic snapshots (replace HTTP polling) case 'stats:snapshot': updateOverviewCards(msg.data); updateMetricsCharts(msg.data); break; case 'health:status': updateConnectionBanner(msg.data.ok); updateMemoryDisplay(msg.data.memory); break; // Queue counts (eliminates the N+1 problem) case 'queue:counts': updateQueueRow(msg.data.queue, msg.data); break; // Real-time activity feed case 'job:completed': case 'job:failed': case 'job:pushed': addToActivityFeed(msg); break; // Worker status case 'worker:connected': addWorkerRow(msg.data); break; case 'worker:disconnected': removeWorkerRow(msg.data.workerId); break; // DLQ alerts case 'dlq:added': incrementDlqCounter(msg.data.queue); showAlert(`Job ${msg.data.jobId} moved to DLQ: ${msg.data.reason}`); break; } return; } // Command response (for interactive operations) if (msg.reqId) { handleCommandResponse(msg); } }; // Interactive: pause a queue from the dashboard function pauseQueue(queue) { ws.send(JSON.stringify({ cmd: 'Pause', queue, reqId: `pause-${queue}` })); } ``` ### Explicit Subscription Events (86) The following names can be supplied directly in a WebSocket `Subscribe` command. Payload fields listed with `?` are present only on the path that has that information; the outer event envelope always supplies `event`, `ts`, and `data`. #### Job Lifecycle (21 events) | Event | Payload | Description | | --------------------------- | ------------------------------------ | ------------------------------------------ | | `job:pushed` | `queue, jobId` | Job added to queue | | `job:active` | `queue, jobId` | Worker picked up job | | `job:completed` | `queue, jobId` | Job finished successfully | | `job:failed` | `queue, jobId, error` | Job errored | | `job:removed` | `queue, jobId, prev?` | Job cancelled/deleted | | `job:promoted` | `jobId` | Delayed job moved to waiting | | `job:progress` | `queue, jobId, progress` | Worker reported progress (0-100) | | `job:delayed` | `queue, jobId, delay` | Job moved to delayed state | | `job:stalled` | `queue, jobId, stallCount?, action?` | Stall detected (no heartbeat) | | `job:retried` | `queue, jobId, prev?` | Failed job retried | | `job:discarded` | `jobId` | Job sent to DLQ via discard | | `job:priority-changed` | `jobId, newPriority` | Priority updated | | `job:data-updated` | `jobId` | Job payload modified | | `job:delay-changed` | `jobId, newDelay` | Delay modified | | `job:timeout` | `queue, jobId, timeout` | Active job exceeded its processing timeout | | `job:lock-expired` | `queue, jobId, renewalCount` | Ownership lock expired | | `job:deduplicated` | `queue, jobId, strategy` | Push reused an existing deduplicated job | | `job:waiting-children` | `queue, jobId, dependsOn?` | Job is waiting for dependencies | | `job:dependencies-resolved` | `queue, jobId` | All dependencies became complete | | `job:moved-to-delayed` | `jobId, delay` | Active job was explicitly moved to delayed | | `job:expired` | `queue, jobId, ttl, age` | Job TTL expired (distinguished from fail) | #### Queue (10 events) | Event | Payload | Description | | ------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `queue:counts` | `queue, waiting, prioritized, active, completed, failed, delayed` | Latest counts after lifecycle activity; updates within 10ms are coalesced. | | `queue:paused` | `queue` | Queue paused | | `queue:resumed` | `queue` | Queue resumed | | `queue:drained` | `queue, count` | All waiting/delayed jobs removed | | `queue:cleaned` | `queue, state, count` | Jobs cleaned by state | | `queue:obliterated` | `queue` | Queue destroyed | | `queue:created` | `queue` | First job pushed to new queue | | `queue:removed` | `queue` | Queue removed | | `queue:idle` | `queue, idleSeconds` | Queue empty with no active jobs for N seconds. Configure via `QUEUE_IDLE_THRESHOLD_MS` (default: 30000). | | `queue:threshold` | `queue, size, threshold` | Queue size exceeds threshold. Configure via `QUEUE_SIZE_THRESHOLD` (default: 0 = disabled). | #### Flow (2 events) | Event | Payload | Description | | ---------------- | ------------------------------------------ | --------------------------------------------------- | | `flow:completed` | `parentJobId, queue, childrenCount` | All children of a flow completed successfully | | `flow:failed` | `parentJobId, failedChildId, queue, error` | A child in a flow failed permanently (moved to DLQ) | #### DLQ (6 events) | Event | Payload | Description | | ------------------ | ---------------------- | ---------------------------------------- | | `dlq:added` | `queue, jobId, reason` | Job moved to DLQ | | `dlq:retried` | `queue, jobId, count` | Single DLQ entry retried | | `dlq:retry-all` | `queue, count` | All DLQ entries retried | | `dlq:purged` | `queue, count` | DLQ emptied | | `dlq:auto-retried` | `queue, count` | Maintenance retried eligible DLQ entries | | `dlq:expired` | `queue, count` | Maintenance purged expired DLQ entries | #### Cron (6 events) | Event | Payload | Description | | -------------- | ---------------------------------------- | --------------------------------------------------------------------- | | `cron:created` | `name, queue, pattern?, every?, nextRun` | Cron added | | `cron:deleted` | `name` | Cron removed | | `cron:fired` | `name, queue` | Cron triggered, job pushed | | `cron:updated` | `name, queue, nextRun` | Cron modified | | `cron:missed` | `name, queue, error` | Cron missed execution window | | `cron:skipped` | `name, queue, reason` | Cron skipped due to overlap (previous instance still within interval) | #### Worker (7 events) | Event | Payload | Description | | ---------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | `worker:connected` | `workerId, name, queues, hostname?, pid?` | Worker registered | | `worker:disconnected` | `workerId, name?, clientId?` | Worker gone | | `worker:heartbeat` | `workerId` | Worker alive signal | | `worker:idle` | `workerId, processedJobs` | Worker reached zero active jobs | | `worker:removed-stale` | `workerId, name` | Stale registration removed | | `worker:overloaded` | `workerId, name, activeJobs, concurrency, overloadedSeconds` | Worker at max concurrency for N seconds. Configure via `WORKER_OVERLOAD_THRESHOLD_MS` (default: 30000). | | `worker:error` | `workerId, name, failedJobs, processedJobs, failureRate` | Worker failure rate is high (emitted at thresholds: 5, 10, 25, 50, 100 failures) | #### Rate Limiting & Concurrency (7 events) | Event | Payload | Description | | ---------------------- | -------------------- | -------------------------------------------------------------------- | | `ratelimit:set` | `queue, max` | Rate limit configured | | `ratelimit:cleared` | `queue` | Rate limit removed | | `ratelimit:hit` | `clientId` | TCP/HTTP client exceeded the protocol request limit | | `ratelimit:rejected` | `queue` | Pull found eligible work but the queue token bucket rejected it | | `concurrency:set` | `queue, concurrency` | Concurrency limit configured | | `concurrency:cleared` | `queue` | Concurrency limit removed | | `concurrency:rejected` | `queue` | Pull found eligible work but no queue concurrency slot was available | #### Webhook (6 events) | Event | Payload | Description | | ------------------ | ------------------------------ | ------------------------------------- | | `webhook:added` | `id, url, events` | Webhook created | | `webhook:removed` | `id` | Webhook deleted | | `webhook:fired` | `webhookId, url, event` | Webhook delivered | | `webhook:failed` | `webhookId, url, event, error` | Webhook delivery failed | | `webhook:enabled` | `webhookId` | Webhook enabled without recreating it | | `webhook:disabled` | `webhookId` | Webhook disabled without deleting it | #### Batch, Client, Auth & Cleanup (7 events) | Event | Payload | Description | | ---------------------------- | ------------------------------------ | ----------------------------------------- | | `batch:pushed` | `queue, total, inserted, duplicates` | Multi-job push inserted at least one job | | `batch:pulled` | `queue, count` | Batch pull delivered more than one job | | `client:connected` | `clientId, transport` | TCP client connected | | `client:disconnected` | `clientId, transport` | TCP client disconnected | | `auth:failed` | `clientId?` or `transport` | TCP command or HTTP authentication failed | | `cleanup:orphans-removed` | `count` | Orphaned processing entries removed | | `cleanup:stale-deps-removed` | `count` | Stale dependency entries removed | #### Periodic, Storage, Server & Memory (12 events) | Event | Payload | Description | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `stats:snapshot` | `waiting, active, completed, dlq, totalPushed, totalCompleted, totalFailed, pushPerSec, pullPerSec, uptime, queues, workers, cronJobs` | Every 5s | | `health:status` | `ok, uptime, memory: { rss, heapUsed }, connections` | Every 10s | | `storage:status` | `collections, diskFull` | Every 30s | | `storage:backup-started` | `bucket` | S3 backup started | | `storage:backup-completed` | `bucket, key` | S3 backup completed | | `storage:backup-failed` | `bucket, error` | S3 backup failed | | `storage:size-warning` | `sizeMB, thresholdMB` | SQLite DB exceeds threshold. Configure via `STORAGE_WARNING_MB` (default: 0 = disabled). | | `server:started` | `tcpPort, httpPort, shards` | Server listeners started | | `server:shutdown` | `signal` | Graceful shutdown began | | `server:recovered` | `queues, jobs` | Persistent queues and jobs recovered on startup | | `server:memory-warning` | `heapUsedMB, thresholdMB, rssMB` | Heap exceeds threshold. Configure via `MEMORY_WARNING_MB` (default: 0 = disabled). | | `memory:compacted` | _(empty object)_ | Manual memory compaction completed | #### Config (2 events) | Event | Payload | Description | | ---------------------- | --------------- | ------------------------------ | | `config:stall-changed` | `queue, config` | Stall detection config updated | | `config:dlq-changed` | `queue, config` | DLQ config updated | ### The `queue:counts` Event This is the most impactful event for dashboards. Job lifecycle activity schedules a refresh for the affected queue; refreshes within a 10ms window are coalesced, and the emitted payload contains the latest counts: ```json { "event": "queue:counts", "ts": 1710000000000, "data": { "queue": "payments", "waiting": 15, "prioritized": 4, "active": 2, "completed": 100, "failed": 0, "delayed": 3 } } ``` **Without `queue:counts`:** A dashboard with 20 queues needs to poll `GET /queues/:q/counts` for each queue every few seconds = 200+ HTTP requests per minute. **With `queue:counts`:** Subscribe once, receive real-time updates only when counts change. Zero polling, instant UI updates. --- ## Endpoint Summary ### Jobs (30 endpoints) | Method | Path | Description | | -------- | ---------------------------- | -------------------- | | `POST` | `/queues/:q/jobs` | Push a job | | `POST` | `/queues/:q/jobs/bulk` | Push jobs in bulk | | `GET` | `/queues/:q/jobs` | Pull a job | | `POST` | `/queues/:q/jobs/pull-batch` | Pull jobs in batch | | `GET` | `/jobs/:id` | Get job by ID | | `GET` | `/jobs/custom/:customId` | Get job by custom ID | | `DELETE` | `/jobs/:id` | Cancel a job | | `POST` | `/jobs/:id/ack` | Acknowledge a job | | `POST` | `/jobs/ack-batch` | Acknowledge batch | | `POST` | `/jobs/:id/fail` | Fail a job | | `GET` | `/jobs/:id/state` | Get job state | | `GET` | `/jobs/:id/result` | Get job result | | `GET` | `/jobs/:id/progress` | Get progress | | `POST` | `/jobs/:id/progress` | Update progress | | `PUT` | `/jobs/:id/data` | Update job data | | `PUT` | `/jobs/:id/priority` | Change priority | | `POST` | `/jobs/:id/promote` | Promote delayed job | | `POST` | `/jobs/:id/move-to-wait` | Move to waiting | | `POST` | `/jobs/:id/move-to-delayed` | Move to delayed | | `PUT` | `/jobs/:id/delay` | Change delay | | `POST` | `/jobs/:id/discard` | Discard to DLQ | | `POST` | `/jobs/:id/wait` | Wait for completion | | `GET` | `/jobs/:id/children` | Get children values | | `POST` | `/jobs/:id/heartbeat` | Job heartbeat | | `POST` | `/jobs/heartbeat-batch` | Job heartbeat batch | | `POST` | `/jobs/:id/extend-lock` | Extend lock | | `POST` | `/jobs/extend-locks` | Extend locks batch | | `GET` | `/jobs/:id/logs` | Get logs | | `POST` | `/jobs/:id/logs` | Add log | | `DELETE` | `/jobs/:id/logs` | Clear logs | ### Queues (15 endpoints) | Method | Path | Description | | ------ | ---------------------------- | ------------------------------- | | `GET` | `/queues` | List all queues | | `GET` | `/queues/summary` | All queues with paused + counts | | `GET` | `/queues/:q/workers` | Workers for a queue | | `GET` | `/queues/:q/jobs/list` | List jobs by state | | `GET` | `/queues/:q/counts` | Job counts per state | | `GET` | `/queues/:q/count` | Total job count | | `GET` | `/queues/:q/priority-counts` | Counts per priority | | `GET` | `/queues/:q/paused` | Check if paused | | `POST` | `/queues/:q/pause` | Pause queue | | `POST` | `/queues/:q/resume` | Resume queue | | `POST` | `/queues/:q/drain` | Drain queue | | `POST` | `/queues/:q/obliterate` | Obliterate queue | | `POST` | `/queues/:q/clean` | Clean old jobs | | `POST` | `/queues/:q/promote-jobs` | Promote delayed jobs | | `POST` | `/queues/:q/retry-completed` | Retry completed jobs | ### DLQ (4 endpoints) | Method | Path | Description | | ------ | ---------------------- | -------------- | | `GET` | `/queues/:q/dlq` | List DLQ jobs | | `GET` | `/queues/:q/dlq/stats` | DLQ statistics | | `POST` | `/queues/:q/dlq/retry` | Retry DLQ jobs | | `POST` | `/queues/:q/dlq/purge` | Purge DLQ | ### Rate Limiting & Concurrency (4 endpoints) | Method | Path | Description | | -------- | ------------------------ | ----------------- | | `PUT` | `/queues/:q/rate-limit` | Set rate limit | | `DELETE` | `/queues/:q/rate-limit` | Clear rate limit | | `PUT` | `/queues/:q/concurrency` | Set concurrency | | `DELETE` | `/queues/:q/concurrency` | Clear concurrency | ### Configuration (4 endpoints) | Method | Path | Description | | --------- | ------------------------- | ---------------------- | | `GET/PUT` | `/queues/:q/stall-config` | Stall detection config | | `GET/PUT` | `/queues/:q/dlq-config` | DLQ config | ### Crons (4 endpoints) | Method | Path | Description | | -------- | -------------- | ----------- | | `GET` | `/crons` | List crons | | `POST` | `/crons` | Add cron | | `GET` | `/crons/:name` | Get cron | | `DELETE` | `/crons/:name` | Delete cron | ### Webhooks (4 endpoints) | Method | Path | Description | | -------- | ----------------------- | -------------- | | `GET` | `/webhooks` | List webhooks | | `POST` | `/webhooks` | Add webhook | | `DELETE` | `/webhooks/:id` | Remove webhook | | `PUT` | `/webhooks/:id/enabled` | Toggle webhook | ### Workers (4 endpoints) | Method | Path | Description | | -------- | ------------------------ | ----------------- | | `GET` | `/workers` | List workers | | `POST` | `/workers` | Register worker | | `DELETE` | `/workers/:id` | Unregister worker | | `POST` | `/workers/:id/heartbeat` | Worker heartbeat | ### Monitoring (11 endpoints) | Method | Path | Auth | Description | | ------ | ------------- | -------- | ------------------ | | `GET` | `/health` | No | Health check | | `GET` | `/healthz` | No | Liveness probe | | `GET` | `/live` | No | Liveness probe | | `GET` | `/ready` | No | Readiness probe | | `GET` | `/ping` | Yes | Ping/pong | | `GET` | `/stats` | Yes | Server statistics | | `GET` | `/metrics` | Yes | Throughput metrics | | `GET` | `/prometheus` | Optional | Prometheus metrics | | `GET` | `/storage` | Yes | Storage health | | `POST` | `/gc` | Yes | Force GC + compact | | `GET` | `/heapstats` | Yes | Heap statistics | ### Dashboard (3 endpoints) | Method | Path | Description | | ------ | ---------------------- | --------------------------- | | `GET` | `/dashboard` | Aggregated overview | | `GET` | `/dashboard/queues` | Paginated queues with stats | | `GET` | `/dashboard/queues/:q` | Single queue detail | ### Real-time (4 channels, 86 pub/sub events) | Protocol | Path | Description | | --------- | ------------------- | -------------------------------------------------- | | SSE | `/events` | All events (legacy format) | | SSE | `/events/queues/:q` | Queue-filtered events | | WebSocket | `/ws` | Pub/sub + commands (86 explicit events, wildcards) | | WebSocket | `/ws/queues/:q` | Queue-filtered pub/sub | :::tip[Related] - [TCP Protocol Reference](/api/tcp/), the same operations over the binary msgpack protocol, with its own command list - [TypeScript Types](/api/types/), type definitions for all APIs - [Server Mode](/guide/server/), run the HTTP API server ::: --- # TCP Protocol Reference: Binary MessagePack Commands TCP protocol spec for bunqueue: MessagePack wire format, pipelining, length-prefixed framing, and full command reference for all operations. URL: https://bunqueue.dev/api/tcp/
api reference · tcp

The wire protocol, documented.

A high-performance binary protocol on port 6789 by default. All messages use MessagePack encoding with length-prefixed framing, and pipelining lets the server process commands concurrently.

## Wire Format Every message (request and response) is wrapped in a length-prefixed frame:
Frame layoutrequest and response
payload length 4 bytes, big-endian unsigned 32-bit
MessagePack payload N bytes
The framing protocol works as follows: 1. The first 4 bytes are a big-endian unsigned 32-bit integer indicating the length of the MessagePack payload. 2. The next N bytes are the MessagePack-encoded command or response object. 3. Maximum frame size is **64 MB**. Frames exceeding this limit cause the connection to be terminated. ### Encoding Example ```typescript import { pack, unpack } from 'msgpackr'; // Encode a command into a framed message function frameCommand(cmd: object): Uint8Array { const payload = pack(cmd); const frame = new Uint8Array(4 + payload.length); // Write length prefix (big-endian u32) frame[0] = (payload.length >> 24) & 0xff; frame[1] = (payload.length >> 16) & 0xff; frame[2] = (payload.length >> 8) & 0xff; frame[3] = payload.length & 0xff; frame.set(payload, 4); return frame; } // Decode a framed response function decodeFrame(frame: Uint8Array): object { return unpack(frame); } ``` ## Connection ```typescript import { pack, unpack } from 'msgpackr'; const socket = await Bun.connect({ hostname: 'localhost', port: 6789, socket: { data(socket, data) { // Parse frames from data, then unpack each frame with msgpackr }, }, }); // Send a command const cmd = pack({ cmd: 'Ping' }); const frame = new Uint8Array(4 + cmd.length); frame[0] = (cmd.length >> 24) & 0xff; frame[1] = (cmd.length >> 16) & 0xff; frame[2] = (cmd.length >> 8) & 0xff; frame[3] = cmd.length & 0xff; frame.set(cmd, 4); socket.write(frame); ``` ## Protocol Negotiation (Hello) Clients should send a `Hello` command after connecting to report their protocol revision and discover server capabilities. **Request:** ```typescript { cmd: 'Hello', protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'] } ``` **Response:** ```typescript { ok: true, protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'], server: 'bunqueue', version: 'x.y.z' // Installed server package version } ``` The current protocol version is **3**. It supports `pipelining` and `separate-job-name`. Revision 3 places job metadata in top-level `job.name` and preserves `job.data` exactly as supplied. The server still accepts legacy inputs with no top-level `name`: at that inbound boundary only, a string `data.name` is decoded as the old embedded-name envelope. ## Pipelining The server supports **pipelining**: clients can send multiple commands without waiting for each response. The server processes frames in parallel with a concurrency limit of **50 commands per connection**, controlled by a semaphore. To correlate responses with requests when pipelining, include a `reqId` field in each command. The server echoes `reqId` back in the corresponding response. ```typescript // Send two commands simultaneously socket.write(frameCommand({ cmd: 'PUSH', queue: 'emails', data: { to: 'a@b.com' }, reqId: '1' })); socket.write(frameCommand({ cmd: 'PUSH', queue: 'emails', data: { to: 'c@d.com' }, reqId: '2' })); // Responses may arrive in any order - match by reqId // { ok: true, id: 'abc-123', reqId: '1' } // { ok: true, id: 'def-456', reqId: '2' } ``` ## Authentication When the server is configured with `AUTH_TOKENS`, all connections must authenticate before sending other commands. The `Auth` command is always permitted regardless of authentication state. **Request:** ```typescript { cmd: 'Auth', token: 'your-secret-token' } ``` **Response (success):** ```typescript { ok: true; } ``` **Response (failure):** ```typescript { ok: false, error: 'Invalid token' } ``` If auth tokens are configured and a client sends any command before authenticating, the server responds with: ```typescript { ok: false, error: 'Not authenticated' } ``` ## Response Format All responses include an `ok` boolean field. On success `ok` is `true` with command-specific data. On failure `ok` is `false` with an `error` string. ```typescript // Success { ok: true, ...data, reqId?: string } // Error { ok: false, error: 'Error message', reqId?: string } ``` ### Queue event frames `SubscribeEvents` selects one queue for the current connection; `UnsubscribeEvents` clears it without closing the socket. Both commands require normal authentication and return a regular `reqId`-correlated response. ```typescript { cmd: 'SubscribeEvents', queue: 'tasks', reqId: 'events-1' } { ok: true, reqId: 'events-1' } // Later, independently of command responses: { type: 'event', event: { eventType: 'completed', queue: 'tasks', jobId: '...', timestamp: 0, data: { ok: true } } } { cmd: 'UnsubscribeEvents', reqId: 'events-2' } ``` The unsolicited event envelope has no `reqId`. Pipelined clients must recognize `type: 'event'` before correlating command responses. A new subscription on the same connection replaces the previous queue. Slow subscribers are subject to the normal per-connection write-buffer limit. ## Connection Lifecycle When a TCP connection closes, the server automatically releases all jobs that were being processed by that client back to their queues. This uses retry logic with exponential backoff (up to 3 attempts) to ensure jobs are not left in an inconsistent state. ## Rate Limiting Each connection is subject to server-side rate limiting. If exceeded, the server responds with: ```typescript { ok: false, error: 'Rate limit exceeded' } ``` --- ## Command Reference Every command object must include a `cmd` field. An optional `reqId` field can be included for request-response correlation (required for pipelining). ### Core Commands #### PUSH Add a single job to a queue. **Request:** ```typescript { cmd: 'PUSH', queue: string, // Queue name (required, max 256 chars, alphanumeric/underscore/dash/dot/colon) name: string, // Job name metadata (required for protocol v3 clients) data: any, // Untouched user payload (required, max 10 MB) priority?: number, // Ungrouped: higher first. With groupId: 0 first, then ascending (0..2097151) delay?: number, // Delay in ms before processing (default: 0, max: 1 year) maxAttempts?: number, // Max retry attempts (default: 3, range: 1-1000) backoff?: number, // Retry backoff delay in ms (default: 1000, max: 1 day) ttl?: number, // Time-to-live in ms (max: 1 year) timeout?: number, // Processing timeout in ms (max: 1 day) uniqueKey?: string, // Deduplication key jobId?: string, // Custom job ID (idempotent) dependsOn?: string[], // Job IDs this job depends on tags?: string[], // Metadata tags groupId?: string, // Job group identifier groupMaxSize?: number, // Positive safe-integer pending-depth admission cap lifo?: boolean, // Last-in-first-out (default: false) removeOnComplete?: boolean, // Auto-remove on completion (default: false) removeOnFail?: boolean, // Auto-remove on failure (default: false) durable?: boolean, // SQLite: bypass write buffer; PostgreSQL is already transactional repeat?: { // Repeat configuration every?: number, // Repeat interval in ms pattern?: string, // Cron expression (alternative to every) limit?: number, // Max repetitions count?: number, // Current count startDate?: number, // Don't fire before this timestamp endDate?: number, // Don't fire after this timestamp tz?: string, // IANA timezone for pattern immediately?: boolean // Fire once on creation }, // Flow / parent-child (used by FlowProducer): parentId?: string, // Parent job ID childrenIds?: string[], // Child job IDs (flow parent) failParentOnFailure?: boolean, removeDependencyOnFailure?: boolean, ignoreDependencyOnFailure?: boolean, continueParentOnFailure?: boolean, // Advanced options: stallTimeout?: number, // Stall detection timeout in ms (max: 1 day) stackTraceLimit?: number, // Cap on stored stack trace lines keepLogs?: number, // Cap on stored log entries sizeLimit?: number, // Max serialized data size for this job dedup?: { ttl?: number, extend?: boolean, replace?: boolean }, // Dedup options (uniqueKey carries the id) debounceId?: string, // Debounce identifier debounceTtl?: number, // Debounce window in ms timestamp?: number // Explicit creation timestamp } ``` The `backoff` field also accepts an object form: `{ type: 'fixed' | 'exponential', delay: number }`. **Response:** ```typescript { ok: true, id: string } // The generated job ID (UUIDv7) ``` --- #### PUSHB Batch push multiple jobs to a queue. **Request:** ```typescript { cmd: 'PUSHB', queue: string, jobs: Array<{ name: string, data: any, priority?: number, delay?: number, maxAttempts?: number, backoff?: number, ttl?: number, timeout?: number, uniqueKey?: string, customId?: string, dependsOn?: string[], tags?: string[], groupId?: string, groupMaxSize?: number, lifo?: boolean, removeOnComplete?: boolean, removeOnFail?: boolean, durable?: boolean }> } ``` Each job is validated with the same rules as `PUSH` (option bounds and `dependsOn` existence). A `dependsOn` entry may also reference the `customId` of any job in the same batch, so order-independent intra-batch chains work. On violation the whole batch is rejected with an error naming the offending index (`jobs[i]: ...`). When `groupId` is present, `priority` is the intra-group priority and must be an integer from `0` through `2,097,151`; lower values run first. `groupMaxSize` makes pending-depth admission atomic. If one member would exceed its group cap, the complete `PUSHB` is rejected without partial writes. **Response:** ```typescript { ok: true, ids: string[] } // Array of generated job IDs ``` --- #### PUSHF Atomically commit a fully resolved, potentially multi-queue FlowProducer graph. This is the command used by the Bun package and all six current official SDKs. Previously published clients may still compose legacy `PUSH`/`UpdateParent` calls. **Request:** ```typescript { cmd: 'PUSHF', jobs: Array<{ id: string, // Final ID; non-empty, no colon queue: string, input: { name: string, data: unknown, // untouched user payload dependsOn?: string[], parentId?: string, childrenIds?: string[], groupId?: string, priority?: number, // with groupId: 0 first, then ascending groupMaxSize?: number, // supported ordinary scheduling/retry/failure options } }> } ``` The complete graph is validated before mutation: strict runtime types, duplicate/missing/asymmetric edges, cycles, policy conflicts, 10,000 jobs, 10 MB per job and 64 MB aggregate data. With configured SQLite, all job rows commit in one immediate transaction before any leaf becomes visible. In PostgreSQL mode commits the graph, dependency edges, and ordered durable events in one database transaction before publication. In memory-only mode, publication is still atomic but not crash-durable. **Response:** ```typescript { ok: true, data: { jobs: Job[] } } ``` The returned array has exactly one authoritative committed snapshot per input ID. Any validation, ownership or persistence error returns `{ ok: false, error }` and publishes no job. --- #### PULL Pull the next available job from a queue. Supports optional long polling and lock-based ownership. **Request:** ```typescript { cmd: 'PULL', queue: string, timeout?: number, // Long poll timeout in ms (0-60000, default: 0) owner?: string, // Client identifier for lock-based pull lockTtl?: number, // Lock TTL in ms (default: 30000) detach?: boolean, // Don't auto-release the job when this connection closes (CLI usage) group?: { concurrency?: number, limit?: { max: number, duration: number } } } ``` **Response (without owner):** ```typescript { ok: true, job: Job | null } ``` **Response (with owner, includes lock token):** ```typescript { ok: true, job: Job | null, token: string | null } ``` The `token` must be passed to `ACK` or `FAIL` to verify ownership. --- #### PULLB Batch pull multiple jobs from a queue. **Request:** ```typescript { cmd: 'PULLB', queue: string, count: number, // Number of jobs to pull (1-1000) timeout?: number, // Long poll timeout in ms (0-60000, default: 0), with or without owner owner?: string, // Client identifier for lock-based pull lockTtl?: number, // Lock TTL in ms (default: 30000) group?: { concurrency?: number, limit?: { max: number, duration: number } } } ``` **Response (without owner):** ```typescript { ok: true, jobs: Job[] } ``` Results are ordered by `createdAt` ascending (oldest first), with the job ID as a deterministic tie-breaker, before `offset` and `limit` are applied. **Response (with owner, includes lock tokens):** ```typescript { ok: true, jobs: Job[], tokens: string[] } ``` --- #### ACK Acknowledge a job as completed. **Request:** ```typescript { cmd: 'ACK', id: string, // Job ID result?: any, // Optional result data token?: string // Lock token (required if pulled with owner) } ``` **Response:** ```typescript { ok: true; } ``` If an exact timeout or retired cron generation already finalized before the ACK claimed it, the response is a successful no-op rather than a retryable transport error: ```typescript { ok: true, data: { applied: false, reason: 'already-finalized' } } ``` --- #### ACKB Batch acknowledge multiple jobs. **Request:** ```typescript { cmd: 'ACKB', ids: string[], // Job IDs results?: any[], // Optional results (same order as ids; if provided, length must match ids) tokens?: string[] // Lock tokens (same order/length as ids; required for leased jobs) } ``` The broker validates every token before completing any item. A missing or incorrect token rejects the whole batch and leaves all jobs, locks, and results unchanged. **Response:** ```typescript { ok: true; } ``` A timeout may win after the batch's lease preflight. Live positions still apply and the broker reports the exact ignored input positions in order: ```typescript { ok: true, data: { ignoredIds: ['job-id'], ignoredIndices: [2] } } ``` Clients must use `ignoredIndices` when IDs repeat. Wrong/missing tokens and ordinary missing/completed jobs remain errors. --- #### FAIL Mark a job as failed. The job will be retried with exponential backoff if it has remaining attempts, otherwise it is moved to the dead-letter queue. **Request:** ```typescript { cmd: 'FAIL', id: string, // Job ID error?: string, // Error message stack?: string[], // Failure stack trace lines, persisted server-side, capped at job.stackTraceLimit (#74) unrecoverable?: boolean, // Skip all remaining retries and fail terminally (straight to DLQ) token?: string // Lock token (required if pulled with owner) } ``` The optional `stack` is stored on the job and surfaced by `GetJob` and on DLQ entries, so a failed job's stack trace survives a restart. **Response:** ```typescript { ok: true; } ``` An exact late generation uses the same successful no-op envelope as `ACK`: ```typescript { ok: true, data: { applied: false, reason: 'already-finalized' } } ``` --- ### Query Commands #### GetJob Retrieve a job by its internal ID. **Request:** ```typescript { cmd: 'GetJob', id: string } ``` **Response:** ```typescript { ok: true, job: Job } ``` Returns an error if the job is not found. --- #### GetState Get the current state of a job. **Request:** ```typescript { cmd: 'GetState', id: string } ``` **Response:** ```typescript { ok: true, id: string, state: string } ``` Possible states: `waiting`, `prioritized`, `delayed`, `active`, `waiting-children`, `completed`, `failed`, or `unknown` (job not found). --- #### GetResult Get the stored result of a completed job. **Request:** ```typescript { cmd: 'GetResult', id: string } ``` **Response:** ```typescript { ok: true, id: string, result: any } ``` The `result` field is the value passed via `ACK`. It may be `null` or `undefined` if no result was stored or if the result has been evicted from the LRU cache. --- #### GetJobs List jobs with filtering and pagination. **Request:** ```typescript { cmd: 'GetJobs', queue: string, state?: JobState | JobState[], // e.g. 'waiting', 'delayed', 'active', 'completed', 'failed', or an array limit?: number, // Max results (default: 100) offset?: number, // Skip N results (default: 0) asc?: boolean // createdAt/id order (default: true) } ``` **Response:** ```typescript { ok: true, jobs: Job[] } ``` Ordering is applied before pagination. Send the same `asc` value on every request when traversing multiple offset pages. --- #### GetJobCounts Get job counts grouped by state for a specific queue. **Request:** ```typescript { cmd: 'GetJobCounts', queue: string } ``` **Response:** ```typescript { ok: true, counts: { waiting: number, prioritized: number, delayed: number, active: number, completed: number, failed: number, 'waiting-children': number, paused: number } } ``` When the queue is paused, ready jobs are reported under `paused` instead of `waiting`/`prioritized` (BullMQ semantics). --- #### GetCountsPerPriority Get job counts grouped by priority level for a specific queue. **Request:** ```typescript { cmd: 'GetCountsPerPriority', queue: string } ``` **Response:** ```typescript { ok: true, queue: string, counts: Record } ``` --- #### GetJobByCustomId Look up a job by its custom ID (the `jobId` field from PUSH). **Request:** ```typescript { cmd: 'GetJobByCustomId', customId: string } ``` **Response:** ```typescript { ok: true, job: Job } ``` Returns an error if no job with that custom ID exists. --- #### Count Get the total number of jobs in a queue (all states). **Request:** ```typescript { cmd: 'Count', queue: string } ``` **Response:** ```typescript { ok: true, count: number } ``` --- #### GetProgress Get the progress of an active job. **Request:** ```typescript { cmd: 'GetProgress', id: string } ``` **Response:** ```typescript { ok: true, progress: number, message: string | null } ``` --- #### GetChildrenValues Get the return values from all child jobs of a parent job. Used with FlowProducer workflows to retrieve results from completed children. **Request:** ```typescript { cmd: 'GetChildrenValues', id: string } ``` **Response:** ```typescript { ok: true, data: { values: Record } } ``` Returns an empty `values` object if the job has no children or if an error occurs. --- ### Control Commands #### Cancel Cancel a waiting or delayed job. **Request:** ```typescript { cmd: 'Cancel', id: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### Progress Update the progress of an active job. **Request:** ```typescript { cmd: 'Progress', id: string, progress: number, // 0-100 message?: string // Optional progress message } ``` **Response:** ```typescript { ok: true; } ``` --- #### Update Update the data payload of an existing job. **Request:** ```typescript { cmd: 'Update', id: string, data: any // New job data } ``` **Response:** ```typescript { ok: true; } ``` --- #### ChangePriority Change the priority of a queued job. **Request:** ```typescript { cmd: 'ChangePriority', id: string, priority: number, lifo?: boolean // Tie-break ordering among same-priority jobs } ``` **Response:** ```typescript { ok: true; } ``` --- #### Promote Move a delayed job to the waiting state immediately. **Request:** ```typescript { cmd: 'Promote', id: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### MoveToDelayed Move an active job back to the delayed state. **Request:** ```typescript { cmd: 'MoveToDelayed', id: string, delay: number, // Delay in ms from now token?: string // Required when the active job has a lock } ``` **Response:** ```typescript { ok: true; } ``` --- #### Discard Discard a job by moving it to the dead-letter queue. **Request:** ```typescript { cmd: 'Discard', id: string, token?: string } ``` When the job has an active lease, `token` must match the current delivery token. For waiting or otherwise unlocked jobs, the field may be omitted for an administrative discard. **Response:** ```typescript { ok: true; } ``` --- #### WaitJob Wait for a job to complete. This is event-driven (no polling). Returns immediately if the job is already completed. **Request:** ```typescript { cmd: 'WaitJob', id: string, timeout?: number // Max wait time in ms (default: 30000, max: 600000) } ``` **Response:** ```typescript { ok: true, completed: boolean, result?: any } ``` --- #### Pause Pause a queue. Workers will stop pulling new jobs. **Request:** ```typescript { cmd: 'Pause', queue: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### Resume Resume a paused queue. **Request:** ```typescript { cmd: 'Resume', queue: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### IsPaused Check whether a queue is currently paused. **Request:** ```typescript { cmd: 'IsPaused', queue: string } ``` **Response:** ```typescript { ok: true, paused: boolean } ``` --- #### Drain Remove all waiting jobs from a queue. **Request:** ```typescript { cmd: 'Drain', queue: string } ``` **Response:** ```typescript { ok: true, count: number } // Number of jobs removed ``` --- #### Obliterate Remove all data for a queue (all jobs in all states). **Request:** ```typescript { cmd: 'Obliterate', queue: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### Clean Remove jobs older than a grace period, optionally filtered by state. **Request:** ```typescript { cmd: 'Clean', queue: string, grace: number, // Grace period in ms - jobs older than this are removed state?: string, // 'waiting'/'delayed'/'prioritized'/'paused' (queued jobs, the default), 'completed', or 'failed' limit?: number // Max jobs to remove (default: 1000) } ``` **Response:** ```typescript { ok: true, count: number, ids: string[] } // IDs of the removed jobs ``` --- #### ListQueues List the names of all known queues. **Request:** ```typescript { cmd: 'ListQueues'; } ``` **Response:** ```typescript { ok: true, queues: string[] } // Queue names ``` For per-queue counts use `GetJobCounts` per queue, or the HTTP `GET /queues/summary` endpoint. --- ### DLQ Commands #### Dlq Retrieve jobs from the dead-letter queue. **Request:** ```typescript { cmd: 'Dlq', queue: string, count?: number, // Max entries to return (optional) filter?: { reason?: string, olderThan?: number, newerThan?: number, retriable?: boolean, expired?: boolean, limit?: number, offset?: number } } ``` **Response:** ```typescript { ok: true, jobs: Job[], entries: DlqEntry[] } ``` --- #### GetDlqStats Read aggregate DLQ health for a queue. ```typescript { cmd: 'GetDlqStats', queue: string } { ok: true, data: { stats: DlqStats } } ``` --- #### RetryDlq Retry jobs from the dead-letter queue (move them back to waiting). **Request:** ```typescript { cmd: 'RetryDlq', queue: string, jobId?: string, // Retry a specific job (optional; omit to retry all) count?: number, // Cap the number of entries retried (omit = retry all) filter?: DlqFilter // Retry only matching entries } ``` **Response:** ```typescript { ok: true, count: number } // Number of jobs retried ``` --- #### PurgeDlq Clear all jobs from the dead-letter queue. **Request:** ```typescript { cmd: 'PurgeDlq', queue: string } ``` **Response:** ```typescript { ok: true, count: number } // Number of jobs purged ``` --- #### RemoveDlqJob Permanently delete one failed job without retrying it. **Request:** ```typescript { cmd: 'RemoveDlqJob', queue: string, jobId: string } ``` **Response:** ```typescript { ok: true, data: { removed: boolean } } ``` `removed: false` is an idempotent miss. Persistence or handler failures return the normal `{ ok: false, error }` response and must not be interpreted as a missing entry. --- #### RetryCompleted Re-queue completed jobs back to waiting state. **Request:** ```typescript { cmd: 'RetryCompleted', queue: string, id?: string, // Retry a specific job (optional; omit to retry all) count?: number, // Non-negative cap timestamp?: number // completedAt must be <= this epoch-ms cutoff } ``` **Response:** ```typescript { ok: true, count: number } ``` --- ### Cron Commands #### Cron Create or update a cron/repeating job schedule. **Request:** ```typescript { cmd: 'Cron', name: string, // Unique cron job name jobName?: string, // First-class name assigned to spawned jobs queue: string, // Target queue data: any, // Job data payload schedule?: string, // Cron expression (e.g., '*/5 * * * *') repeatEvery?: number, // Positive safe-integer ms (schedule wins if both exist) priority?: number, // Job priority maxLimit?: number, // Max executions timezone?: string, // IANA timezone (e.g., 'Europe/Rome', 'America/New_York') uniqueKey?: string, // Deduplication key for cron-spawned jobs dedup?: { ttl?: number, extend?: boolean, replace?: boolean }, // Dedup options for spawned jobs skipMissedOnRestart?: boolean, // Skip missed runs on restart instead of executing them (default true) immediately?: boolean, // Fire once on creation, then continue on schedule (default false) skipIfNoWorker?: boolean, // Skip a tick when no worker is registered (default false) preventOverlap?: boolean, // Skip a tick while the previous run is still pending/active (default true) jobOptions?: { // Per-job options applied to every generated job maxAttempts?: number, backoff?: number | { type: 'fixed' | 'exponential', delay: number }, timeout?: number, delay?: number, stallTimeout?: number, removeOnComplete?: boolean, removeOnFail?: boolean } } ``` **Response:** ```typescript { ok: true, cron: { name: string, jobName: string, queue: string, schedule: string | null, repeatEvery: number | null, nextRun: number, executions: number, maxLimit: number | null, timezone: string | null, priority: number } } ``` --- #### CronDelete Delete a cron job schedule by name. **Request:** ```typescript { cmd: 'CronDelete', name: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### CronList List all registered cron job schedules. **Request:** ```typescript { cmd: 'CronList'; } ``` **Response:** ```typescript { ok: true, crons: Array<{ name: string, jobName: string, queue: string, schedule: string | null, repeatEvery: number | null, nextRun: number, executions: number, maxLimit: number | undefined, timezone: string | undefined }> } ``` --- #### CronGet Get a single cron job by name. **Request:** ```typescript { cmd: 'CronGet', name: string } ``` **Response:** ```typescript { ok: true, cron: { name: string, jobName: string, queue: string, schedule: string | null, repeatEvery: number | null, nextRun: number, executions: number, maxLimit: number | undefined, timezone: string | undefined } } ``` Returns an error if the cron job is not found. --- ### Monitoring Commands #### Ping Connection health check. **Request:** ```typescript { cmd: 'Ping'; } ``` **Response:** ```typescript { ok: true, data: { pong: true, time: number } } ``` --- #### Hello Protocol version negotiation and server capability discovery. See the [Protocol Negotiation](#protocol-negotiation-hello) section above for details. **Request:** ```typescript { cmd: 'Hello', protocolVersion: number, capabilities?: Array<'pipelining' | 'separate-job-name'> } ``` **Response:** ```typescript { ok: true, protocolVersion: number, capabilities: Array<'pipelining' | 'separate-job-name'>, server: 'bunqueue', version: string } ``` --- #### Stats Get high-level server statistics. **Request:** ```typescript { cmd: 'Stats'; } ``` **Response:** ```typescript { ok: true, stats: { waiting: number, // Waiting jobs active: number, // Active jobs delayed: number, // Delayed jobs dlq: number, // Dead-letter queue size completed: number, // Completed count failed: number, // Failed (totalFailed) count uptime: number, // Server uptime in ms pushPerSec: number, // Push throughput pullPerSec: number // Pull throughput } } ``` --- #### Metrics Get detailed server metrics. The request without queue fields retains the legacy broker-wide response shown below. **Request:** ```typescript { cmd: 'Metrics'; } ``` **Response:** ```typescript { ok: true, metrics: { totalPushed: number, totalPulled: number, totalCompleted: number, totalFailed: number, avgLatencyMs: number, avgProcessingMs: number, memoryUsageMb: number, sqliteSizeMb: number, activeConnections: number } } ``` For durable queue-scoped minute metrics, send: ```typescript { cmd: 'Metrics', queue: 'emails', type: 'completed', // or 'failed' start: 0, // newest bucket index end: -1 // through the oldest retained bucket } ``` ```typescript { ok: true, data: { meta: { count: number, prevTS: number, prevCount: number }, data: number[], // one-minute buckets, newest first count: number // bucket count before pagination } } ``` #### TrimEvents Keep only the newest lifecycle events for one queue. The response reports the exact removed count, so repeating the request at the same length returns zero. ```typescript { cmd: 'TrimEvents', queue: 'emails', maxLength: 1000 } ``` ```typescript { ok: true, data: { removed: number } } ``` --- #### Prometheus Get metrics in Prometheus text exposition format. **Request:** ```typescript { cmd: 'Prometheus'; } ``` **Response:** ```typescript { ok: true, data: { metrics: string } } ``` --- #### StorageStatus Get the storage/disk health status. Reports whether the disk is full or has errors. **Request:** ```typescript { cmd: 'StorageStatus'; } ``` **Response:** ```typescript { ok: true, data: { diskFull: boolean, // Whether the disk is full error: string | null, // Error message if any since: number | null // Timestamp when the issue started (ms since epoch) } } ``` --- #### Heartbeat Send a heartbeat for a registered worker (keeps the worker registration alive). **Request:** ```typescript { cmd: 'Heartbeat', id: string, // Worker ID activeJobs?: number, // Optional stats update processed?: number, failed?: number } ``` **Response:** ```typescript { ok: true, data: { ok: true } } ``` --- #### JobHeartbeat Send a heartbeat for an active job (prevents stall detection from marking it as stalled). Also renews the lock if a token is provided. **Request:** ```typescript { cmd: 'JobHeartbeat', id: string, // Job ID token?: string, // Lock token for renewal duration?: number // Lock renewal duration in ms (with token: extends the lock) } ``` **Response:** ```typescript { ok: true, data: { ok: true } } ``` --- #### JobHeartbeatB Batch job heartbeat for multiple active jobs. **Request:** ```typescript { cmd: 'JobHeartbeatB', ids: string[], // Job IDs tokens?: string[] // Lock tokens (same order as ids) } ``` **Response:** ```typescript { ok: true, data: { ok: true, count: number } } ``` --- ### Worker Commands #### RegisterWorker Register a worker with the server for monitoring. **Request:** ```typescript { cmd: 'RegisterWorker', name: string, queues: string[], // Queues this worker processes concurrency?: number, workerId?: string, // Reuse a stable worker ID across reconnects hostname?: string, pid?: number, startedAt?: number } ``` **Response:** ```typescript { ok: true, data: { workerId: string, name: string, queues: string[], concurrency: number, hostname: string | undefined, pid: number | undefined, status: 'active', registeredAt: number, lastSeen: number, activeJobs: number, processedJobs: number, failedJobs: number, currentJob: string | null } } ``` The registration is tied to the TCP connection: the server auto-unregisters the worker when the connection closes. --- #### UnregisterWorker Remove a worker registration. **Request:** ```typescript { cmd: 'UnregisterWorker', workerId: string } ``` **Response:** ```typescript { ok: true, data: { removed: true } } ``` --- #### ListWorkers List all registered workers and their stats. **Request:** ```typescript { cmd: 'ListWorkers'; } ``` **Response:** ```typescript { ok: true, data: { workers: Array<{ id: string, name: string, queues: string[], concurrency: number, hostname: string | undefined, pid: number | undefined, status: 'active' | 'stale', // stale = no heartbeat within WORKER_TIMEOUT_MS (default 30s) registeredAt: number, lastSeen: number, activeJobs: number, processedJobs: number, failedJobs: number, currentJob: string | null, uptime: number }>, stats: object // Aggregated worker stats } } ``` --- ### Webhook Commands #### AddWebhook Register a webhook to receive event notifications. URLs are validated to prevent SSRF (localhost, private IPs, and cloud metadata endpoints are blocked). **Request:** ```typescript { cmd: 'AddWebhook', url: string, // Webhook URL (https required for production) events: string[], // Event types to subscribe to queue?: string, // Filter by queue (optional) secret?: string // Signing secret for payload verification } ``` **Response:** ```typescript { ok: true, data: { webhookId: string, url: string, events: string[], queue: string | undefined, createdAt: number } } ``` --- #### RemoveWebhook Remove a registered webhook. **Request:** ```typescript { cmd: 'RemoveWebhook', webhookId: string } ``` **Response:** ```typescript { ok: true, data: { removed: true } } ``` --- #### ListWebhooks List all registered webhooks. **Request:** ```typescript { cmd: 'ListWebhooks'; } ``` **Response:** ```typescript { ok: true, data: { webhooks: Array<{ id: string, url: string, events: string[], queue: string | undefined, createdAt: number, lastTriggered: number | null, successCount: number, failureCount: number, enabled: boolean }>, stats: object } } ``` --- ### Rate Limiting Commands #### RateLimit Set a rate limit on a queue: `limit` jobs per `duration` ms (default 1000, so jobs per second). **Request:** ```typescript { cmd: 'RateLimit', queue: string, limit: number, // Max jobs per window duration?: number, // Window in ms (default 1000) ttl?: number // Auto-expiry in ms: the server clears the limit itself } ``` Invalid `duration` or `ttl` values (non-finite or not positive) fall back to the defaults (1 second window, permanent limit) instead of failing. Servers older than 2.8.35 ignore both optional fields. **Response:** ```typescript { ok: true; } ``` --- #### RateLimitClear Remove the rate limit from a queue. **Request:** ```typescript { cmd: 'RateLimitClear', queue: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### SetConcurrency Set a concurrency limit on a queue (max concurrent active jobs). **Request:** ```typescript { cmd: 'SetConcurrency', queue: string, limit: number } ``` **Response:** ```typescript { ok: true; } ``` --- #### ClearConcurrency Remove the concurrency limit from a queue. **Request:** ```typescript { cmd: 'ClearConcurrency', queue: string } ``` **Response:** ```typescript { ok: true; } ``` --- #### GetQueueLimits Read the live rate/concurrency configuration and saturation state. ```typescript { cmd: 'GetQueueLimits', queue: string, maxJobs?: number } { ok: true, data: { limits: { rateLimit: { max: number, duration: number } | null, rateLimitTtl: number, // -2 when no rate limit exists concurrencyLimit: number | null, maxed: boolean } } } ``` --- #### Job group controls and getters Group depth excludes active jobs and includes waiting, prioritized and delayed jobs. Every response below is wrapped in `data`: ```typescript { cmd: 'GetGroupJobsCount', queue, groupId } // -> { ok: true, data: { count: number } } { cmd: 'GetGroupsJobsCount', queue, maxCount? } // -> { ok: true, data: { count: number } } { cmd: 'GetGroupActiveCount', queue, groupId } // -> { ok: true, data: { count: number } } { cmd: 'SetGroupRateLimit', queue, groupId, max, duration } { cmd: 'GetGroupRateLimit', queue, groupId } // -> { ok: true, data: { limit: { max, duration } | null } } { cmd: 'RemoveGroupRateLimit', queue, groupId } // -> { ok: true, data: { removed: 0 | 1 } } { cmd: 'GetGroupRateLimitTtl', queue, groupId, maxJobs? } // -> { ok: true, data: { ttl: number } } { cmd: 'SetGroupConcurrency', queue, groupId, concurrency } { cmd: 'GetGroupConcurrency', queue, groupId } // -> { ok: true, data: { concurrency: number | null } } { cmd: 'RemoveGroupConcurrency', queue, groupId } // -> { ok: true, data: { removed: 0 | 1 } } { cmd: 'PauseGroup', queue, groupId } // -> { ok: true, data: { changed: boolean } } { cmd: 'ResumeGroup', queue, groupId } // -> { ok: true, data: { changed: boolean } } { cmd: 'IsGroupPaused', queue, groupId } // -> { ok: true, data: { paused: boolean } } { cmd: 'RateLimitGroup', queue, groupId, duration } // -> { ok: true } ``` Group IDs are non-empty strings of at most 256 characters. `max`, `duration`, and `concurrency` must be positive safe integers. Stored overrides affect a claim only when `PULL`/`PULLB` carries the corresponding `group` default. Pause blocks only new claims from that group. `RateLimitGroup` installs an immediately effective manual deadline even when the Worker has no group-rate default. --- #### Deduplication Introspection ```typescript { cmd: 'GetDeduplicationJobId', queue: string, deduplicationId: string } // -> { ok: true, data: { jobId: string | null } } { cmd: 'RemoveDeduplicationKey', queue: string, deduplicationId: string } // -> { ok: true, data: { count: number } } { cmd: 'RemoveJobDeduplicationKey', id: string } // -> { ok: true, data: { removed: boolean } } ``` The job-owned form removes a key only when the requested job is still its registered owner. --- #### MoveToWaitingChildren ```typescript { cmd: 'MoveToWaitingChildren', id: string, token?: string } // -> { ok: true, data: { moved: true } } ``` The job must be active. The transition releases its active resources and persists the parked state. If the job has a lock, `token` must match it. --- ### Log Commands #### AddLog Add a log entry to a job. **Request:** ```typescript { cmd: 'AddLog', id: string, // Job ID message: string, // Log message level?: 'info' | 'warn' | 'error' // Log level (default: 'info') } ``` **Response:** ```typescript { ok: true, data: { added: true } } ``` --- #### GetLogs Get all log entries for a job. **Request:** ```typescript { cmd: 'GetLogs', id: string, start?: number, end?: number } // start/end: inclusive pagination indexes ``` **Response:** ```typescript { ok: true, data: { logs: Array<{ message: string, level: string, timestamp: number }>, count: number } } ``` `count` is the total number of stored log entries (before pagination). Logs are capped at 100 entries per job. --- ### Lock Commands #### ExtendLock Extend the lock TTL on an active job (lock-based processing). **Request:** ```typescript { cmd: 'ExtendLock', id: string, duration: number, token?: string } ``` **Response:** `{ ok: true }` or `{ ok: false, error: 'Lock not found or invalid token' }` --- #### ExtendLocks Batch variant of `ExtendLock` (positional arrays, same order). **Request:** ```typescript { cmd: 'ExtendLocks', ids: string[], tokens: string[], durations: number[] } ``` **Response:** ```typescript { ok: true, count: number } // Number of locks successfully extended ``` --- ### More Job Commands #### ChangeDelay Change the delay of a delayed job (recomputes `runAt`). **Request:** `{ cmd: 'ChangeDelay', id: string, delay: number, token?: string }` `token` is required when the job is active and currently leased. Worker processor Job objects forward their current delivery token automatically; unlocked administrative transitions may omit it. **Response:** `{ ok: true }` --- #### MoveToWait Move a job back to `waiting`, dispatching by current state: `active` is released back to the queue, `delayed` is promoted, `failed` is retried from the DLQ, `waiting`/`prioritized` is a no-op success. **Request:** `{ cmd: 'MoveToWait', id: string, token?: string }` **Response:** `{ ok: true }` For an active locked job, `token` is required and must match the current lease. An active job without a lock can still be moved administratively. --- #### PromoteJobs Promote all (or up to `count`) delayed jobs in a queue to waiting. **Request:** `{ cmd: 'PromoteJobs', queue: string, count?: number }` **Response:** `{ ok: true, count: number }` --- #### ClearLogs Clear a job's log entries, optionally keeping the most recent N. **Request:** `{ cmd: 'ClearLogs', id: string, keepLogs?: number }` **Response:** `{ ok: true }` --- #### SetWebhookEnabled Enable or disable a webhook without deleting it. **Request:** `{ cmd: 'SetWebhookEnabled', id: string, enabled: boolean }` **Response:** `{ ok: true }` or `{ ok: false, error: 'Webhook not found' }` --- #### CompactMemory Trigger internal memory compaction. **Request:** `{ cmd: 'CompactMemory' }` **Response:** `{ ok: true }` --- ### Flow Dependency Commands Used by FlowProducer for parent/child job graphs. #### UpdateParent **Request:** `{ cmd: 'UpdateParent', childId: string, parentId: string }` **Response:** `{ ok: true }` This is a compatibility command for legacy multi-request flow creation. If the parent already declares `childId`, only the child's temporary parent marker is updated; the parent may be active or terminal and its state/topology is not rewritten. A queued, active, completed, DLQ, or `removeOnComplete`-tombstoned child is accepted when that declared edge is consistent. Persisted job/DLQ data and any failure-outbox key move atomically. A genuinely new edge still requires a queued parent; conflicting ownership, self-links, and undeclared missing nodes fail. #### GetFailedChildrenValues **Request:** `{ cmd: 'GetFailedChildrenValues', id: string }` **Response:** `{ ok: true, values: Record }` #### GetIgnoredChildrenFailures **Request:** `{ cmd: 'GetIgnoredChildrenFailures', id: string }` **Response:** `{ ok: true, values: Record }` #### RemoveChildDependency **Request:** `{ cmd: 'RemoveChildDependency', id: string }` **Response:** `{ ok: true, removed: boolean }` #### RemoveUnprocessedChildren **Request:** `{ cmd: 'RemoveUnprocessedChildren', id: string }` **Response:** `{ ok: true }` --- ### Queue Config Commands #### SetStallConfig / GetStallConfig Per-queue stall detection configuration. Numeric fields: `stallInterval`, `maxStalls`, `gracePeriod` (numeric strings are coerced, non-numeric values are dropped). **Request:** ```typescript { cmd: 'SetStallConfig', queue: string, config: { stallInterval?: number, maxStalls?: number, gracePeriod?: number } } { cmd: 'GetStallConfig', queue: string } ``` **Response:** `{ ok: true }` for set, `{ ok: true, config: {...} }` for get. #### SetDlqConfig / GetDlqConfig Per-queue DLQ configuration. Numeric fields: `autoRetryInterval`, `maxAutoRetries`, `maxAge`, `maxEntries`. **Request:** ```typescript { cmd: 'SetDlqConfig', queue: string, config: { autoRetry?: boolean, autoRetryInterval?: number, maxAutoRetries?: number, maxAge?: number | null, maxEntries?: number } } { cmd: 'GetDlqConfig', queue: string } ``` **Response:** `{ ok: true }` for set, `{ ok: true, config: {...} }` for get. --- ### Dashboard Commands Aggregated read-only snapshots for dashboards (same data as the HTTP `/dashboard` endpoints). #### DashboardOverview **Request:** `{ cmd: 'DashboardOverview' }` **Response:** `{ ok: true, data: { stats, throughput, latency, memory, collections, workers, crons, storage, timestamp } }` #### DashboardQueues **Request:** `{ cmd: 'DashboardQueues' }` **Response:** `{ ok: true, data: { queues: Array<{ name, waiting, prioritized, delayed, active, dlq, paused }>, timestamp } }` #### DashboardQueue **Request:** `{ cmd: 'DashboardQueue', queue: string, includeJobs?: boolean, jobsLimit?: number }` (`jobsLimit` default 10, max 50) **Response:** `{ ok: true, data: { name, counts, paused, priorityCounts, dlqPreview, jobs?, timestamp } }` --- ## Queue Name Validation Queue names must satisfy the following constraints: - Not empty and at most 256 characters - Only alphanumeric characters, underscores, dashes, dots, and colons: `[a-zA-Z0-9_\-.:]+` ## Job Data Limits Job data payloads are limited to **10 MB** when serialized. ## Command Summary | Category | Command | Description | | -------------- | ------------------------------------------------------------------------ | -------------------------------------------- | | **Core** | `PUSH` | Add a job to a queue | | | `PUSHB` | Batch push multiple jobs | | | `PULL` | Pull next job (supports long poll and locks) | | | `PULLB` | Batch pull jobs | | | `ACK` | Acknowledge job completion | | | `ACKB` | Batch acknowledge | | | `FAIL` | Mark job as failed | | **Query** | `GetJob` | Get job by ID | | | `GetState` | Get job state | | | `GetResult` | Get job result | | | `GetJobs` | List jobs with filtering | | | `GetJobCounts` | Count jobs by state | | | `GetCountsPerPriority` | Count jobs by priority | | | `GetJobByCustomId` | Look up job by custom ID | | | `Count` | Total job count for a queue | | | `GetProgress` | Get job progress | | | `GetChildrenValues` | Get child job return values | | | `GetQueueLimits` | Read live queue rate/concurrency status | | | `GetDeduplicationJobId` | Resolve a queue-scoped deduplication key | | **Control** | `Cancel` | Cancel a job | | | `Progress` | Update job progress | | | `Update` | Update job data | | | `ChangePriority` | Change job priority | | | `Promote` | Move delayed job to waiting | | | `MoveToDelayed` | Move active job to delayed | | | `MoveToWaitingChildren` | Park an active job for children | | | `ChangeDelay` | Change a delayed job's delay | | | `MoveToWait` | Move a job back to waiting | | | `PromoteJobs` | Promote all delayed jobs in a queue | | | `Discard` | Move job to DLQ | | | `WaitJob` | Wait for job completion | | | `ExtendLock` | Extend a job lock | | | `ExtendLocks` | Extend job locks (batch) | | | `RemoveDeduplicationKey` | Release a queue-scoped deduplication key | | | `RemoveJobDeduplicationKey` | Release only a job-owned key | | | `Pause` | Pause a queue | | | `Resume` | Resume a queue | | | `IsPaused` | Check if queue is paused | | | `Drain` | Remove all waiting jobs | | | `Obliterate` | Remove all queue data | | | `Clean` | Remove old jobs | | | `ListQueues` | List all queues | | **DLQ** | `Dlq` | Get DLQ entries | | | `GetDlqStats` | Get aggregate DLQ statistics | | | `RetryDlq` | Retry DLQ jobs | | | `PurgeDlq` | Clear DLQ | | | `RemoveDlqJob` | Permanently delete one DLQ job | | | `RetryCompleted` | Re-queue completed jobs | | **Cron** | `Cron` | Create/update cron schedule | | | `CronDelete` | Delete cron schedule | | | `CronList` | List cron schedules | | | `CronGet` | Get cron schedule by name | | **Monitoring** | `Ping` | Health check | | | `Hello` | Protocol negotiation | | | `Stats` | Server statistics | | | `Metrics` | Detailed metrics | | | `TrimEvents` | Trim one queue's lifecycle journal | | | `Prometheus` | Prometheus-format metrics | | | `StorageStatus` | Get storage/disk health status | | | `Heartbeat` | Worker heartbeat | | | `JobHeartbeat` | Job heartbeat (stall prevention) | | | `JobHeartbeatB` | Batch job heartbeat | | **Workers** | `RegisterWorker` | Register a worker | | | `UnregisterWorker` | Unregister a worker | | | `ListWorkers` | List workers | | **Webhooks** | `AddWebhook` | Register a webhook | | | `RemoveWebhook` | Remove a webhook | | | `ListWebhooks` | List webhooks | | | `SetWebhookEnabled` | Enable/disable a webhook | | **Rate** | `RateLimit` | Set queue rate limit | | | `RateLimitClear` | Clear queue rate limit | | | `SetConcurrency` | Set queue concurrency limit | | | `ClearConcurrency` | Clear concurrency limit | | **Job groups** | `GetGroupJobsCount` / `GetGroupsJobsCount` | Read grouped backlog | | | `GetGroupActiveCount` | Read active jobs in one group | | | `SetGroupRateLimit` / `GetGroupRateLimit` / `RemoveGroupRateLimit` | Manage one group's rate override | | | `GetGroupRateLimitTtl` | Read one group's fixed-window TTL | | | `SetGroupConcurrency` / `GetGroupConcurrency` / `RemoveGroupConcurrency` | Manage one group's concurrency override | | | `PauseGroup` / `ResumeGroup` / `IsGroupPaused` | Control and read one group's pause state | | | `RateLimitGroup` | Install an immediate manual group deadline | | **Config** | `SetStallConfig` | Set per-queue stall config | | | `GetStallConfig` | Get per-queue stall config | | | `SetDlqConfig` | Set per-queue DLQ config | | | `GetDlqConfig` | Get per-queue DLQ config | | **Logs** | `AddLog` | Add job log entry | | | `GetLogs` | Get job logs | | | `ClearLogs` | Clear job logs | | **Flow** | `UpdateParent` | Update a child's parent reference | | | `GetFailedChildrenValues` | Failed children values | | | `GetIgnoredChildrenFailures` | Ignored children failures | | | `RemoveChildDependency` | Remove a child's parent dependency | | | `RemoveUnprocessedChildren` | Remove unprocessed children | | **Dashboard** | `DashboardOverview` | Aggregated dashboard snapshot | | | `DashboardQueues` | All queues with stats | | | `DashboardQueue` | Single queue detail | | **System** | `CompactMemory` | Trigger memory compaction | | **Auth** | `Auth` | Authenticate connection | :::tip[Related] - [HTTP API Reference](/api/http/) - REST API alternative - [TypeScript Types](/api/types/) - Type definitions - [TCP Protocol Architecture](/architecture/tcp-protocol/) - Protocol internals ::: --- # TypeScript Types: Job, Queue, Worker & DLQ Complete TypeScript type definitions for bunqueue. Includes Job, Queue, Worker, DLQ, and connection interfaces with full generic support. URL: https://bunqueue.dev/api/types/
api reference · types

Every TypeScript type, spelled out.

bunqueue is written in TypeScript and provides comprehensive type definitions. All public types are exported from bunqueue/client.

## Job Types ### JobStateType ```typescript type JobStateType = | 'waiting' // In queue, priority = 0 | 'prioritized' // In queue, priority > 0 (BullMQ v5) | 'delayed' // Waiting for delay to expire | 'active' // Currently being processed | 'completed' // Successfully finished | 'failed' // Failed after all retries (DLQ) | 'waiting-children' // Waiting for child jobs to complete (flows) | 'unknown'; // Job not found or invalid state ``` :::note[BullMQ v5 State Machine] bunqueue implements the full BullMQ v5 job state machine:
Job state machineBullMQ v5
push priority = 0
waiting
push priority > 0
prioritized
push delay > 0
delayed
waiting / prioritized delay expires
↓ pull
active
completed ack
failed fail (terminal)
waiting / prioritized retry
flow dependencies
active
waiting-children
waiting children complete
**Key differences from BullMQ v5:** - `failed` = BullMQ's failed state. Internally stored in DLQ with metadata (reason, attempt history). - `prioritized` = BullMQ's prioritized state. Jobs with `priority > 0` are in a separate logical state but share the same priority queue data structure. - `waiting-children` = Parent jobs waiting for child flows to complete before becoming processable. ::: ### Job The main job interface returned by Queue methods and passed to worker processors. ```typescript interface Job { // ── Core Properties ────────────────────────────────────────── /** Unique job identifier (UUIDv7) */ id: string; /** Job name/type */ name: string; /** Job payload data */ data: T; /** Queue name this job belongs to */ queueName: string; /** Number of processing attempts made */ attemptsMade: number; /** Job creation timestamp (ms since epoch) */ timestamp: number; /** Current progress (0-100) */ progress: number; /** Return value after successful completion */ returnvalue?: unknown; /** Error message if the job failed */ failedReason?: string; /** Parent job reference (if this job is part of a flow) */ parent?: { id: string; queueQualifiedName: string }; // ── Scheduling & Timing ────────────────────────────────────── /** Delay in ms before job becomes available for processing */ delay: number; /** Timestamp when job started processing */ processedOn?: number; /** Timestamp when job finished (completed or failed) */ finishedOn?: number; /** Ungrouped: higher runs sooner. Grouped: 0 runs first, then ascending. */ priority: number; // ── Failure & Stall Tracking ───────────────────────────────── /** Stack traces from failed attempts */ stacktrace: string[] | null; /** Number of times this job has been stalled */ stalledCounter: number; // ── Metadata ───────────────────────────────────────────────── /** Parent key in format queueName:jobId */ parentKey?: string; /** Original job options used when adding this job */ opts: JobOptions; /** Lock token for this job (present when processing) */ token?: string; /** Worker/client identifier processing this job */ processedBy?: string; /** Deduplication ID (if set via jobId or deduplication option) */ deduplicationId?: string; /** Repeat job key (for repeatable jobs) */ repeatJobKey?: string; /** Number of times job processing has been started (includes retries) */ attemptsStarted: number; // ── Core Methods ───────────────────────────────────────────── /** Update job progress (0-100) with optional status message */ updateProgress(progress: number, message?: string): Promise; /** Add a log entry to the job */ log(message: string): Promise; /** Get the current state of the job */ getState(): Promise; /** Remove this job from the queue */ remove(): Promise; /** * Retry this job. State-dispatched per BullMQ v5 contract: * - `failed` → requeue from DLQ (throws if not present) * - `active` → move to waiting (throws if move fails) * - `waiting`/`prioritized`/`delayed` → no-op * - other states → throws */ retry(): Promise; /** * Get the return values of all children jobs. * Keys are job keys (queueName:jobId), values are return values. */ getChildrenValues(): Promise>; // ── State Check Methods ────────────────────────────────────── /** Check if job is in waiting state */ isWaiting(): Promise; /** Check if job is currently active/processing */ isActive(): Promise; /** Check if job is delayed */ isDelayed(): Promise; /** Check if job has completed successfully */ isCompleted(): Promise; /** Check if job has failed */ isFailed(): Promise; /** Check if job is waiting for children to complete */ isWaitingChildren(): Promise; // ── Mutation Methods ───────────────────────────────────────── /** Update the job's data payload */ updateData(data: T): Promise; /** Promote a delayed job to the waiting state */ promote(): Promise; /** Change the delay on a delayed job */ changeDelay(delay: number): Promise; /** Change the job's priority */ changePriority(opts: ChangePriorityOpts): Promise; /** * Extend the job's lock duration. Returns the new duration on success, 0 if the lock * could not be extended (wrong token, lock expired, or no active lock). */ extendLock(token: string, duration: number): Promise; /** Clear job logs, optionally keeping the last N entries */ clearLogs(keepLogs?: number): Promise; /** * Discard this job. Marks it to not be processed further. * The job will be moved to failed state with a "discarded" reason. */ discard(): void; // ── Dependency Methods ─────────────────────────────────────── /** Get job dependencies (children) with pagination */ getDependencies(opts?: GetDependenciesOpts): Promise; /** Get count of job dependencies */ getDependenciesCount(opts?: GetDependenciesOpts): Promise; /** Get return values of failed children jobs */ getFailedChildrenValues(): Promise>; /** Get ignored child failures (via ignoreDependencyOnFailure) */ getIgnoredChildrenFailures(): Promise>; /** Remove this job's dependency relationship with its parent */ removeChildDependency(): Promise; /** * Remove the deduplication key associated with this job. * Returns false when this job no longer owns the key (for example, after a * replacement generation acquired it). */ removeDeduplicationKey(): Promise; /** Remove all unprocessed child jobs of this job */ removeUnprocessedChildren(): Promise; /** * Return every member of the current native processor batch. * Present only on jobs delivered through WorkerOptions.batch. */ getBatch?(): Job[]; /** * Fail only this member while allowing the rest of its native batch to * complete. Present only on jobs delivered through WorkerOptions.batch. */ setAsFailed?(error: Error): void; // ── Move Methods ───────────────────────────────────────────── /** * Move job to completed state. * @param returnValue - The return value of the job * @param token - Exact lock token, required when the job has a lock * @param fetchNext - Accepted for BullMQ signature compatibility. bunqueue * Workers fetch their next job through the polling loop, so this method does * not perform a chained fetch. * @returns null after the transition */ moveToCompleted(returnValue: unknown, token?: string, fetchNext?: boolean): Promise; /** * Move job to failed state. * @param error - The error that caused the failure * @param token - Exact lock token, required when the job has a lock * @param fetchNext - Accepted for BullMQ signature compatibility. bunqueue * Workers fetch their next job through the polling loop, so this method does * not perform a chained fetch. */ moveToFailed(error: Error, token?: string, fetchNext?: boolean): Promise; /** * Move job back to waiting state. * @param token - Exact lock token, required when the job has a lock * @returns true if job was moved */ moveToWait(token?: string): Promise; /** * Move job to delayed state. * @param timestamp - When the job should become available * @param token - Exact lock token, required when the job has a lock */ moveToDelayed(timestamp: number, token?: string): Promise; /** * Move job to waiting-children state. * Job will wait for all children to complete before processing. * @param token - Exact lock token, required when the job has a lock * @param opts - Options including child reference * @returns true if job was moved * Available in both embedded and TCP mode. */ moveToWaitingChildren( token?: string, opts?: { child?: { id: string; queue: string } } ): Promise; /** * Wait until the job has finished (completed or failed). * @param queueEvents - QueueEvents instance to listen on * @param ttl - Maximum time to wait in ms (optional) * @returns The job's return value * @throws Error if job fails or times out */ waitUntilFinished(queueEvents: unknown, ttl?: number): Promise; // ── Serialization Methods ──────────────────────────────────── /** Get job as a typed JSON object */ toJSON(): JobJson; /** Get job as raw JSON (all values stringified) */ asJSON(): JobJsonRaw; } ``` ### JobJson Typed JSON representation of a job. ```typescript interface JobJson { id: string; name: string; data: T; opts: JobOptions; progress: number; delay: number; timestamp: number; attemptsMade: number; stacktrace: string[] | null; returnvalue?: unknown; failedReason?: string; finishedOn?: number; processedOn?: number; queueQualifiedName: string; parentKey?: string; } ``` ### JobJsonRaw Raw JSON representation with all values as strings. ```typescript interface JobJsonRaw { id: string; name: string; data: string; // JSON stringified opts: string; // JSON stringified progress: string; // JSON stringified delay: string; timestamp: string; attemptsMade: string; stacktrace: string | null; // JSON stringified returnvalue?: string; // JSON stringified failedReason?: string; finishedOn?: string; processedOn?: string; parentKey?: string; } ``` ### ChangePriorityOpts ```typescript interface ChangePriorityOpts { /** New priority value */ priority: number; /** Process in LIFO order after priority change */ lifo?: boolean; } ``` ### GetDependenciesOpts ```typescript interface GetDependenciesOpts { processed?: { cursor?: number; count?: number }; unprocessed?: { cursor?: number; count?: number }; } ``` ### JobDependencies ```typescript interface JobDependencies { processed: Record; unprocessed: string[]; nextProcessedCursor?: number; nextUnprocessedCursor?: number; } ``` ### JobDependenciesCount ```typescript interface JobDependenciesCount { processed: number; unprocessed: number; } ``` ### JobOptions Options when adding a job to a queue. ```typescript interface JobOptions { /** Ungrouped job priority (higher = processed sooner, default: 0) */ priority?: number; /** Delay in milliseconds before job becomes available (default: 0) */ delay?: number; /** Maximum number of processing attempts (default: 3) */ attempts?: number; /** * Backoff between retries. Either a delay in ms or a BackoffOptions object. * Default: 1000 */ backoff?: number | BackoffOptions; /** Processing timeout in milliseconds. Job fails if exceeded. */ timeout?: number; /** * Custom job ID for idempotent/deduplication. * If a job with this ID already exists, the existing job is returned. */ jobId?: string; /** * Remove job on completion. Boolean only: age/count retention * (number | KeepJobs) is not implemented and would be silently * ignored, so the type is narrowed to prevent the misuse. * Default: false */ removeOnComplete?: boolean; /** Remove job on failure. Boolean only, see removeOnComplete. Default: false */ removeOnFail?: boolean; /** Stall timeout in ms. Job is stalled if no heartbeat after this time. */ stallTimeout?: number; /** Repeat configuration for recurring jobs */ repeat?: RepeatOptions; /** * Request immediate admission persistence. * SQLite bypasses its write buffer; PostgreSQL admissions are already transactional. * Default: false (uses the SQLite buffer when that backend is selected) */ durable?: boolean; /** * Parent job reference for flow dependencies. * When set, this job becomes a child of the specified parent. * The parent will wait for all children to complete before processing. */ parent?: ParentOpts; /** Process jobs in LIFO order (newest first, default: false) */ lifo?: boolean; /** Maximum stack trace lines to store on failure (default: 10) */ stackTraceLimit?: number; /** Maximum number of log entries to keep per job */ keepLogs?: number; /** Maximum job data size in bytes. Jobs exceeding this are rejected. */ sizeLimit?: number; /** Fail parent job if this child job fails (default: false) */ failParentOnFailure?: boolean; /** Remove dependency relationship if this job fails (default: false) */ removeDependencyOnFailure?: boolean; /** Continue parent processing even if this child fails (default: false) */ continueParentOnFailure?: boolean; /** Move job to parent's failed dependencies instead of blocking parent (default: false) */ ignoreDependencyOnFailure?: boolean; /** Job creation timestamp in ms (default: Date.now()) */ timestamp?: number; /** Deduplication configuration */ deduplication?: DeduplicationOptions; /** Debounce configuration */ debounce?: DebounceOptions; /** Round-robin/FIFO job group membership */ group?: GroupJobOptions; } ``` ### GroupJobOptions ```typescript interface GroupJobOptions { /** Non-empty group identifier; safe integers are normalized to strings */ id: string | number; /** Maximum pending jobs admitted atomically for this group */ maxSize?: number; /** Integer from 0 to 2,097,151; lower values run first */ priority?: number; } ``` ### ParentOpts ```typescript interface ParentOpts { /** Parent job ID */ id: string; /** Parent job queue name */ queue: string; } ``` ### BackoffOptions ```typescript interface BackoffOptions { /** Backoff strategy type */ type: 'fixed' | 'exponential'; /** Base delay in milliseconds */ delay: number; } ``` All backoff delays include automatic **jitter** to prevent thundering herd: - **Exponential**: ±50% jitter around the computed delay - **Fixed**: ±20% jitter around the configured delay Delays are capped at 1 hour by default. This prevents runaway delays at high attempt counts. ### KeepJobs Exported for BullMQ compatibility. Not accepted by per-job `removeOnComplete`/`removeOnFail` (those are boolean only); the equivalent shape is accepted by `WorkerOptions.removeOnComplete`/`removeOnFail`. ```typescript interface KeepJobs { /** Maximum age in milliseconds */ age?: number; /** Maximum count of jobs to keep */ count?: number; } ``` ### RepeatOptions Configuration for recurring/repeatable jobs. ```typescript interface RepeatOptions { /** Repeat every N milliseconds (alternative to pattern) */ every?: number; /** Maximum repetitions (omit or null for infinite) */ limit?: number; /** Cron pattern (alternative to every) */ pattern?: string; /** Start date for repeat jobs */ startDate?: Date | string | number; /** End date for repeat jobs */ endDate?: Date | string | number; /** Timezone for cron pattern (e.g. 'America/New_York') */ tz?: string; /** Execute immediately on start (default: false) */ immediately?: boolean; /** Current repeat count (internal) */ count?: number; /** Previous execution timestamp (internal) */ prevMillis?: number; /** Offset in milliseconds */ offset?: number; /** Custom job ID for repeat jobs */ jobId?: string; } ``` ### DeduplicationOptions Prevent duplicate jobs from being added to the queue. ```typescript interface DeduplicationOptions { /** Unique deduplication ID (required) */ id: string; /** TTL in milliseconds for the deduplication key */ ttl?: number; /** Extend TTL when a duplicate job arrives (for debounce mode) */ extend?: boolean; /** Replace job data when duplicate arrives while in delayed state */ replace?: boolean; } ``` ### DebounceOptions Debounce job creation within a time window. ```typescript interface DebounceOptions { /** Unique debounce ID (required) */ id: string; /** TTL in milliseconds for the debounce window (required) */ ttl: number; } ``` ### Processor ```typescript interface ProcessorContext { signal: AbortSignal; } interface ObservableLike { subscribe(observer: { next(value: T): void; error(error: unknown): void; complete(): void; }): { unsubscribe(): void } | (() => void) | undefined; } type Processor = ( job: Job, context?: ProcessorContext ) => Promise | ObservableLike | R; ``` The Worker supplies a fresh `AbortSignal` for every delivery. A processing timeout, `worker.cancelJob()`, or `worker.cancelAllJobs()` aborts it. Promise processors are cooperative and must pass the signal to cancellable work or check `signal.aborted`; an ignored signal does not forcibly stop JavaScript. A structural Observable needs no RxJS dependency: its final `next` value is the job result, `error` fails the attempt, completion without a value fails, and abort unsubscribes it. `FlowJobData` contains the optional flow-injected fields (`__parentId`, `__parentQueue`, `__childrenIds`, `__flowParentId`, `__flowParentIds`) that FlowProducer adds to broker-backed `job.data` when a job is part of a flow. They are engine-owned: flow creation rejects caller `__*` keys, and `updateData()` preserves the existing values rather than allowing them to be removed or replaced. ### JobCounts Returned by `queue.getJobCounts()`. ```typescript interface JobCounts { waiting: number; prioritized: number; active: number; completed: number; failed: number; delayed: number; paused: number; } ``` ## Queue Types ### QueueOptions ```typescript interface QueueOptions { /** Default job options applied to all jobs in this queue */ defaultJobOptions?: JobOptions; /** TCP connection options (for server mode) */ connection?: ConnectionOptions; /** Use embedded mode (in-process memory or SQLite, default: false) */ embedded?: boolean; /** * SQLite path for embedded mode. A later explicit path must match the * process-wide manager's active database or construction throws. */ dataPath?: string; /** * Auto-batching for queue.add() calls in TCP mode. * Buffers concurrent add() calls and sends them as a single PUSHB command. * Default: enabled for TCP mode, disabled for embedded mode. */ autoBatch?: AutoBatchOptions; /** * Namespace prefix prepended to the queue name on the server. * Lets multiple environments (e.g. `dev:`, `prod:`) or tenants share * the same broker without their jobs, workers, cron schedulers, stats, * pause state, DLQ, or rate limits overlapping. `Queue.name` keeps * returning the logical name; only the server-side key is prefixed. * See the [Namespace Isolation](/guide/queue/advanced/#namespace-isolation-prefixkey) * guide. */ prefixKey?: string; } ``` ### AutoBatchOptions ```typescript interface AutoBatchOptions { /** Enable auto-batching (default: true for TCP, false for embedded) */ enabled?: boolean; /** Max items before auto-flush (default: 50) */ maxSize?: number; /** Max delay in ms before auto-flush (default: 5) */ maxDelayMs?: number; } ``` Jobs added with `durable: true` bypass the batcher and are sent as individual PUSH commands. ### ConnectionOptions ```typescript interface ConnectionOptions { /** Server hostname (default: 'localhost', ignored if socketPath is set) */ host?: string; /** TCP port (default: 6789, ignored if socketPath is set) */ port?: number; /** Unix socket path (takes priority over host/port) */ socketPath?: string; /** Enable TLS to the server: true (system CAs) or custom options (default: off) */ tls?: boolean | ClientTlsOptions; /** Authentication token */ token?: string; /** * Connection pool size for parallel operations. * Source JSDoc default: 1. Runtime default for Queue/FlowProducer: 4. * Set >1 to enable connection pooling. */ poolSize?: number; /** Ping interval in ms for health checks (default: 30000, 0 to disable) */ pingInterval?: number; /** Command timeout in ms (default: 30000) */ commandTimeout?: number; /** * Consecutive command timeouts (no intervening success) before the connection * is concluded dead and a reconnect is forced (default: 3, 0 to disable). * Recovery path for a half-open socket, independent of the health-check ping. */ maxCommandTimeouts?: number; /** Enable TCP pipelining (default: true) */ pipelining?: boolean; /** Max commands in flight per connection (default: 100) */ maxInFlight?: number; } ``` ### ClientTlsOptions ```typescript interface ClientTlsOptions { /** Verify the server certificate (default: true). Set false for self-signed in dev. */ rejectUnauthorized?: boolean; /** Path to a PEM CA certificate to trust (e.g. the self-signed server cert) */ caFile?: string; } ``` ### RateLimiterOptions ```typescript interface RateLimiterOptions { /** Maximum number of jobs to process in the duration window */ max: number; /** Duration window in milliseconds */ duration: number; /** Optional group key for per-group rate limiting */ groupKey?: string; } ``` ## Worker Types ### WorkerOptions ```typescript interface WorkerOptions { /** Number of concurrent jobs (default: 1) */ concurrency?: number; /** Auto-run on creation (default: true) */ autorun?: boolean; /** Heartbeat interval in ms (default: 10000, 0 to disable) */ heartbeatInterval?: number; /** TCP connection options (for server mode) */ connection?: ConnectionOptions; /** Use embedded mode (in-process memory or SQLite, default: false) */ embedded?: boolean; /** * SQLite path for embedded mode. A later explicit path must match the * process-wide manager's active database or construction throws. */ dataPath?: string; /** Number of jobs to pull per batch (default: 10, max: 1000) */ batchSize?: number; /** Long poll timeout in ms when queue is empty (default: 0, max: 30000) */ pollTimeout?: number; /** * Use lock-based job ownership. * When enabled, each pulled job gets a lock renewed via heartbeat. * Disable for high-throughput scenarios where stall detection is sufficient. * Default: true */ useLocks?: boolean; /** Rate limiter configuration for controlling job processing rate */ limiter?: RateLimiterOptions; /** Broker-authoritative job-group defaults; omitted means unlimited/disabled */ group?: GroupWorkerOptions; /** Native BullMQ Pro-compatible batch processing */ batch?: BatchWorkerOptions; /** Lock duration in ms (default: 30000). Sent to the server on pull; also used by stall detection. */ lockDuration?: number; /** Max stalls before moving to failed (default: 1). Applied to stall config in embedded mode. */ maxStalledCount?: number; /** Skip stalled job check, disables the stalled event subscription (default: false) */ skipStalledCheck?: boolean; /** Skip lock renewal via heartbeat (default: false) */ skipLockRenewal?: boolean; /** Delay in ms between polls when the queue is drained (default: 50) */ drainDelay?: number; /** Remove jobs on complete, applied as default for all jobs processed by this worker */ removeOnComplete?: boolean | number | { age?: number; count?: number }; /** Remove jobs on fail, applied as default for all jobs processed by this worker */ removeOnFail?: boolean | number | { age?: number; count?: number }; /** * Namespace prefix; must match the producing `Queue.prefixKey` to * consume its jobs. The Worker is registered under `prefixKey + name` * on the server, so two workers with the same logical queue name but * different prefixes never see each other's jobs. See the * [Namespace Isolation](/guide/queue/advanced/#namespace-isolation-prefixkey) guide. */ prefixKey?: string; } ``` ### GroupWorkerOptions ```typescript interface GroupWorkerOptions { /** Maximum active jobs per group; omitted means unlimited */ concurrency?: number; /** Fixed-window starts allowed independently for every group */ limit?: { max: number; duration: number }; } ``` ### BatchWorkerOptions ```typescript interface BatchWorkerOptions { /** Maximum jobs in one processor invocation (1..1000) */ size: number; /** Minimum members before starting (default: 1; must be <= size) */ minSize?: number; /** Maximum wait for minSize in ms; omitted/0 waits indefinitely */ timeout?: number; /** Keep every member in a batch on the same group ID (default: false) */ groupAffinity?: boolean; } ``` With `batch`, `concurrency` counts concurrent processor invocations, while each invocation may own up to `batch.size` independently leased jobs. The processor is called once with a leading job; `job.getBatch()` returns all members and `member.setAsFailed(error)` selectively fails one. Without `groupAffinity`, a batch that contains grouped work does not wait for `minSize`. With affinity, the broker and Worker keep one group ID per batch and the minimum-size wait is honored. ### Worker Pro control methods ```typescript worker.cancelJob(jobId: string, reason?: string): boolean; worker.cancelAllJobs(reason?: string): void; worker.isJobCancelled(jobId: string): boolean; worker.rateLimitGroup(job: Job, duration: number): Promise; ``` Cancellation targets only deliveries active in that Worker and aborts their processor signals. `rateLimitGroup` requires a grouped active job, installs a broker-authoritative manual deadline, and moves that delivery back to waiting. It does not require `WorkerOptions.group.limit`. ### Worker Events ```typescript // Worker emits these events: worker.on('ready', () => void); worker.on('active', (job: Job) => void); worker.on('completed', (job: Job, result: R) => void); worker.on('failed', (job: Job, error: Error) => void); worker.on('progress', (job: Job | null, progress: number) => void); worker.on('stalled', (jobId: string, reason: string) => void); worker.on('cancelled', (data: { jobId: string; reason: string }) => void); worker.on('log', (job: Job, message: string) => void); worker.on('error', (error: Error) => void); worker.on('drained', () => void); worker.on('closed', () => void); ``` ## QueueEvents Types ### QueueEvents Event listener class for monitoring queue activity without processing jobs. ```typescript class QueueEvents extends EventEmitter { /** Queue name being monitored */ readonly name: string; constructor(name: string, options?: QueueEventsOptions); /** Wait until the QueueEvents instance is ready to receive events */ waitUntilReady(): Promise; /** Close the event listener and stop receiving events */ close(): void; /** Disconnect from the event stream (alias for close) */ disconnect(): Promise; } interface QueueEventsOptions { embedded?: boolean; connection?: ConnectionOptions; /** Must match the process-wide embedded manager when one is already active. */ dataPath?: string; prefixKey?: string; } ``` Calling `new QueueEvents(name)` keeps the historical embedded default. Pass `{ connection }` or `{ embedded: false, connection }` to subscribe to a remote broker. The TCP subscription uses a dedicated authenticated connection and automatically re-subscribes after reconnect. ## BullMQ Pro aliases ```typescript import { QueuePro, WorkerPro, QueueEventsPro } from 'bunqueue/client'; import type { JobPro } from 'bunqueue/client'; ``` `QueuePro`, `WorkerPro`, and `QueueEventsPro` are aliases of the native `Queue`, `Worker`, and `QueueEvents` implementations; `JobPro` aliases `Job`. They add no wrapper state, connection, persistence path, or telemetry. The aliases make Pro-oriented migrations explicit while retaining the ordinary class names and behavior. ### QueueEvents Event Payloads Each event emitted by `QueueEvents` has a typed payload: ```typescript /** Emitted when a job is added to the queue */ interface WaitingEvent { jobId: string; } /** Emitted when a job begins processing */ interface ActiveEvent { jobId: string; } /** Emitted when a job completes successfully */ interface CompletedEvent { jobId: string; returnvalue: R; } /** Emitted when a job fails */ interface FailedEvent { jobId: string; failedReason: string; data?: unknown; } /** Emitted when job progress is updated */ interface ProgressEvent

{ jobId: string; data: P; } /** Emitted when a job stalls (no heartbeat) */ interface StalledEvent { jobId: string; } /** Emitted when a job is removed from the queue */ interface RemovedEvent { jobId: string; prev: string; } /** Emitted when a job is moved to delayed state */ interface DelayedEvent { jobId: string; delay: number; } /** Emitted when a duplicate job is detected */ interface DuplicatedEvent { jobId: string; } /** Emitted when a job is retried */ interface RetriedEvent { jobId: string; prev: string; } /** Emitted when a job enters waiting-children state */ interface WaitingChildrenEvent { jobId: string; } /** Emitted when the queue has no more waiting jobs */ interface DrainedEvent { id: string; } ``` ### QueueEvents Usage ```typescript const events = new QueueEvents('my-queue', { connection: { host: '127.0.0.1', port: 6789, token: process.env.BUNQUEUE_TOKEN }, }); events.on('waiting', ({ jobId }) => { /* ... */ }); events.on('active', ({ jobId }) => { /* ... */ }); events.on('completed', ({ jobId, returnvalue }) => { /* ... */ }); events.on('failed', ({ jobId, failedReason }) => { /* ... */ }); events.on('progress', ({ jobId, data }) => { /* ... */ }); events.on('stalled', ({ jobId }) => { /* ... */ }); events.on('removed', ({ jobId, prev }) => { /* ... */ }); events.on('delayed', ({ jobId, delay }) => { /* ... */ }); events.on('duplicated', ({ jobId }) => { /* ... */ }); events.on('retried', ({ jobId, prev }) => { /* ... */ }); events.on('waiting-children', ({ jobId }) => { /* ... */ }); events.on('drained', ({ id }) => { /* ... */ }); events.on('paused', () => { /* ... */ }); events.on('resumed', () => { /* ... */ }); events.on('error', (error: Error) => { /* ... */ }); ``` ## QueueEventType ```typescript type QueueEventType = 'waiting' | 'active' | 'completed' | 'failed' | 'progress' | 'removed' | 'drained'; ``` ## FlowProducer Types ### FlowProducerOptions ```typescript interface FlowProducerOptions { /** Use embedded mode (no server) */ embedded?: boolean; /** TCP connection options */ connection?: ConnectionOptions; } ``` :::note FlowProducer extends `EventEmitter` (BullMQ v5 compatible). You can listen for events using `.on()`, `.once()`, etc. The `close()` method returns `Promise`. `closing` is `null` while live and becomes that one stable Promise when `close()` or `disconnect()` first starts shutdown. ::: Result helpers are transport-aware: ```typescript getParentResult(id: string): R | undefined | Promise; getParentResults(ids: string[]): Map | Promise>; ``` Always `await` them in portable code. Embedded mode preserves its synchronous return; TCP mode performs broker `GetResult` calls. Missing is `undefined`, whereas a persisted `null` is retained as a real completed result. ### FlowOpts Per-flow options passed as the second argument to `flow.add(flowJob, opts)`. ```typescript interface FlowOpts { /** * Default job options per queue name. * Applied as defaults; per-job opts override these. * jobId is intentionally excluded because identity belongs to each flow node. */ queuesOptions?: Record, 'jobId'>>; } ``` Set `jobId` on a `FlowJob.opts` object when a node needs a custom identity. `jobId` is rejected inside `queuesOptions`: a shared default could assign the same identity to multiple nodes and make the atomic graph ambiguous. Python follows the same rule using `job_id` inside each node's `opts`, never inside `queues_options`. **Example:** ```typescript await flow.add( { name: 'parent', queueName: 'reports', children: [ { name: 'fetch', queueName: 'api', data: { url: '...' } }, { name: 'parse', queueName: 'cpu', data: {} }, ], }, { queuesOptions: { api: { attempts: 5, backoff: 2000 }, // All jobs in 'api' queue cpu: { timeout: 60000 }, // All jobs in 'cpu' queue }, } ); ``` ### FlowJob A job definition within a flow. Children are processed before the parent. ```typescript interface FlowJob { /** Job name */ name: string; /** Queue name */ queueName: string; /** Job data */ data?: T; /** Job options */ opts?: JobOptions; /** Child jobs (processed BEFORE this job) */ children?: FlowJob[]; } ``` ### JobNode Result from adding a flow. Contains the created job and its children. ```typescript interface JobNode { /** The created job instance */ job: Job; /** Child nodes (if any) */ children?: JobNode[]; } ``` ### GetFlowOpts ```typescript interface GetFlowOpts { /** Job ID to get the flow for */ id: string; /** Queue name where the job is located */ queueName: string; /** Maximum depth to traverse (default: unlimited) */ depth?: number; /** Maximum number of children to fetch per level (default: unlimited) */ maxChildren?: number; } ``` ## SandboxedWorker Types ### SandboxedWorkerOptions ```typescript interface SandboxedWorkerOptions { /** Path to processor file (must export default async function) */ processor: string; /** Number of worker processes (default: 1) */ concurrency?: number; /** Job timeout in ms (default: 30000, 0 = disabled) */ timeout?: number; /** Max memory per worker in MB (default: 256, uses smol mode if <= 64) */ maxMemory?: number; /** Max restarts before giving up (default: 10) */ maxRestarts?: number; /** Auto-restart crashed workers (default: true) */ autoRestart?: boolean; /** Poll interval in ms when no workers are idle (default: 10) */ pollInterval?: number; /** Heartbeat interval in ms for TCP lock renewal (default: 10000 for TCP, 0 for embedded) */ heartbeatInterval?: number; /** TCP connection options (omit for embedded mode) */ connection?: ConnectionOptions; /** Auto-stop after this many ms of inactivity (0 = disabled, default: 0) */ idleTimeout?: number; /** Recycle individual idle worker processes after this many ms (default: 30000, 0 = disabled) */ idleRecycleMs?: number; /** Auto-restart the worker pool when new jobs arrive after idle shutdown (default: false) */ autoStart?: boolean; /** Poll interval in ms for checking new jobs while in idle-shutdown state (default: 5000) */ autoStartPollMs?: number; } ``` ### SandboxedWorker Stats Returned by `sandboxedWorker.getStats()`. ```typescript { total: number; // Total worker processes busy: number; // Currently processing idle: number; // Alive and available for work recycled: number; // Workers recycled after idling restarts: number; // Total restarts across all workers } ``` ## Stall Detection Types ### StallConfig ```typescript interface StallConfig { /** Enable stall detection (default: true) */ enabled?: boolean; /** Stall timeout in ms (default: 30000) */ stallInterval?: number; /** Max stalls before moving to DLQ (default: 3) */ maxStalls?: number; /** Grace period after job start in ms (default: 5000) */ gracePeriod?: number; } ``` ## DLQ Types ### DlqConfig ```typescript interface DlqConfig { /** Enable auto-retry from DLQ (default: false) */ autoRetry?: boolean; /** Auto-retry interval in ms (default: 3600000 = 1 hour) */ autoRetryInterval?: number; /** Max auto-retries before giving up (default: 3) */ maxAutoRetries?: number; /** Max age before auto-purge in ms (default: 604800000 = 7 days, null = never) */ maxAge?: number | null; /** Max entries per queue (default: 10000) */ maxEntries?: number; } ``` ### FailureReason ```typescript type FailureReason = | 'explicit_fail' // Job explicitly failed via fail() or thrown error | 'max_attempts_exceeded' // Exceeded all retry attempts | 'timeout' // Job processing timed out (exceeded timeout option) | 'stalled' // Job stalled (no heartbeat within stallInterval) | 'ttl_expired' // Time-to-live expired before processing | 'worker_lost' // Worker disconnected while processing (TCP mode) | 'unknown'; // Catch-all for edge cases ``` :::note[When is 'unknown' used?] The `unknown` reason is a catch-all for rare edge cases: - Job data corruption during serialization - Internal queue manager errors - Jobs recovered from database without failure metadata - Race conditions during shutdown If you see many `unknown` failures, check logs for underlying errors. ::: ### DlqEntry ```typescript interface DlqEntry { /** The failed job */ job: Job; /** When job entered DLQ (ms since epoch) */ enteredAt: number; /** Last failure reason */ reason: FailureReason; /** Last error message */ error: string | null; /** Full attempt history */ attempts: Array; /** Number of retry attempts from DLQ */ retryCount: number; /** Last retry timestamp */ lastRetryAt: number | null; /** Next scheduled auto-retry (null = no auto-retry) */ nextRetryAt: number | null; /** When entry expires for auto-purge (null = never) */ expiresAt: number | null; } ``` ### AttemptRecord ```typescript interface AttemptRecord { /** Attempt number (1-based) */ attempt: number; /** When this attempt started (ms since epoch) */ startedAt: number; /** When this attempt failed (ms since epoch) */ failedAt: number; /** Failure reason for this attempt */ reason: FailureReason; /** Error message if any */ error: string | null; /** Duration of this attempt in ms */ duration: number; } ``` ### DlqFilter ```typescript interface DlqFilter { /** Filter by failure reason */ reason?: FailureReason; /** Only entries older than this timestamp */ olderThan?: number; /** Only entries newer than this timestamp */ newerThan?: number; /** Only entries that can be retried */ retriable?: boolean; /** Only entries that are expired */ expired?: boolean; /** Limit number of results */ limit?: number; /** Offset for pagination */ offset?: number; } ``` ### DlqStats ```typescript interface DlqStats { /** Total DLQ entries */ total: number; /** Entries grouped by failure reason */ byReason: Record; /** Entries awaiting auto-retry */ pendingRetry: number; /** Expired entries (awaiting cleanup) */ expired: number; /** Oldest entry timestamp (null if empty) */ oldestEntry: number | null; /** Newest entry timestamp (null if empty) */ newestEntry: number | null; } ``` ## Generic Type Helpers bunqueue supports generic types for type-safe job data and results: ```typescript // Define typed job data interface EmailJobData { to: string; subject: string; body: string; } interface EmailResult { sent: boolean; messageId: string; } // Queue with typed data const queue = new Queue('emails'); // TypeScript enforces the data shape await queue.add('welcome', { to: 'user@example.com', subject: 'Welcome!', body: 'Hello and welcome.', }); // Worker with typed data and result const worker = new Worker('emails', async (job) => { // job.data is typed as EmailJobData const { to, subject, body } = job.data; return { sent: true, messageId: 'msg-123' }; }); // Type error at compile time: missing required fields await queue.add('send', { to: 'test@example.com' }); // Error! // QueueEvents with typed result and progress const events = new QueueEvents('emails'); events.on('completed', ({ jobId, returnvalue }) => { // returnvalue is typed as EmailResult console.log(returnvalue.messageId); }); ``` :::tip[Related] - [Queue API](/guide/queue/) - Queue usage with these types - [Worker API](/guide/worker/) - Worker usage with these types - [HTTP API Reference](/api/http/) - HTTP endpoints reference ::: --- # Cron Jobs in Bun: Scheduled Background Tasks Schedule recurring jobs with cron expressions or intervals. Persist schedules in SQLite or coordinate them across PostgreSQL brokers, with timezone support. URL: https://bunqueue.dev/guide/cron/ import { Tabs, TabItem } from '@astrojs/starlight/components';

guide · cron jobs

Work that runs on a clock.

Nightly reports, hourly cleanups, a health ping every thirty seconds. Schedules live in the selected persistent backend: beside jobs in SQLite, or transactionally coordinated across a PostgreSQL broker fleet. Memory-only mode does not survive a restart.

A scheduler is a named rule that keeps producing jobs on a queue. Create it once with `upsertJobScheduler()` and bunqueue fires the job on every tick, whether that tick comes from a cron pattern or a fixed interval. Scheduler IDs are global to the selected backend—not scoped by queue—and, in PostgreSQL mode, shared by every broker in the namespace. Use names such as `reports:daily-report` when several applications share a deployment. ## Quick Start Create a scheduler with `upsertJobScheduler`. It works in both embedded and TCP mode, in every SDK (Python: `upsert_job_scheduler`, Rust: `upsert_job_scheduler`, Go: `UpsertJobScheduler`, Elixir: `upsert_scheduler`), and calling it again with the same global ID replaces that scheduler definition instead of duplicating it. Reusing an ID with a different queue moves the definition to that queue. ```typescript import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('reports', { embedded: true }); // Every day at 9:00 AM await queue.upsertJobScheduler( 'daily-report', { pattern: '0 9 * * *', }, { name: 'daily-report', data: { type: 'sales' }, } ); // A normal worker processes the scheduled jobs new Worker( 'reports', async (job) => { console.log('Running report:', job.data.type); }, { embedded: true } ); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; const queue = new Queue('reports'); // Every day at 9:00 AM await queue.upsertJobScheduler( 'daily-report', { pattern: '0 9 * * *', }, { name: 'daily-report', data: { type: 'sales' }, } ); // A normal worker processes the scheduled jobs new Worker('reports', async (job) => { console.log('Running report:', job.data.type); }); ``` ```python from bunqueue import Queue, Worker queue = Queue("reports") # Every day at 9:00 AM queue.upsert_job_scheduler("daily-report", {"pattern": "0 9 * * *"}, {"name": "daily-report", "data": {"type": "sales"}}) # A normal worker processes the scheduled jobs def process(job): print("Running report:", job.data["type"]) Worker("reports", process).run() ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('reports'); // Every day at 9:00 AM $queue->upsertJobScheduler('daily-report', ['pattern' => '0 9 * * *'], ['name' => 'daily-report', 'data' => ['type' => 'sales']]); // A normal worker processes the scheduled jobs $worker = new Worker('reports', function (Bunqueue\Job $job) { $data = $job->data(); echo "Running report: {$data['type']}\n"; }); $worker->run(); ``` ```go queue := bunqueue.NewQueue("reports", bunqueue.Options{}) // Every day at 9:00 AM err := queue.UpsertJobScheduler("daily-report", bunqueue.SchedulerRepeat{Pattern: "0 9 * * *"}, bunqueue.SchedulerTemplate{ Name: "daily-report", Data: map[string]any{"type": "sales"}, }) // A normal worker processes the scheduled jobs worker := bunqueue.NewWorker("reports", func(job *bunqueue.Job) (any, error) { fmt.Println("Running report:", job.Data()["type"]) return nil, nil }, bunqueue.WorkerOptions{}) worker.Run() ``` ```rust use bunqueue_client::{ ConnectionOptions, Queue, SchedulerRepeat, SchedulerTemplate, Value, Worker, WorkerOptions, }; let queue = Queue::new("reports", ConnectionOptions::default()); // Every day at 9:00 AM queue.upsert_job_scheduler( "daily-report", SchedulerRepeat { pattern: Some("0 9 * * *".into()), ..Default::default() }, SchedulerTemplate { name: Some("daily-report".into()), data: Value::Map(vec![(Value::from("type"), Value::from("sales"))]), ..Default::default() }, )?; // A normal worker processes the scheduled jobs let worker = Worker::new( "reports", |job| { println!("Running report: {:?}", job.data()); Ok(Value::Nil) }, WorkerOptions::default(), ); worker.run()?; ``` ```elixir queue = Bunqueue.queue("reports") # Every day at 9:00 AM :ok = Bunqueue.Queue.upsert_scheduler(queue, "daily-report", %{pattern: "0 9 * * *"}, %{name: "daily-report", data: %{type: "sales"}} ) # A normal worker processes the scheduled jobs worker = Bunqueue.Worker.new("reports", fn job -> IO.puts("Running report: #{job.data["type"]}") {:ok, nil} end) Bunqueue.Worker.run(worker) ``` Or from the CLI, against a running server: ```bash bunqueue cron add daily-report -q reports -d '{"type":"daily"}' -s "0 9 * * *" bunqueue cron list bunqueue cron delete daily-report ``` ## Where to go next | | | | --------------------------------------------------------- | --------------------------------------------------- | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules on the Queue object | | [Cron Recipes](/guide/cron/recipes/) | Fixed intervals, timezones, repeat-after-completion | | [Cron Reference](/guide/cron/reference/) | Expression syntax, every scheduler option, MCP | --- # S3 Backup: Automatic Off-Site Copies of Your Queue Automated S3 backups for the bunqueue SQLite database. Works with AWS S3, Cloudflare R2, MinIO, and DigitalOcean Spaces. URL: https://bunqueue.dev/guide/backup/
server · s3 backup

The whole queue, backed up to S3.

In SQLite mode, bunqueue stores queue state in one database file. Turn on S3 backup and the server uploads a compressed, checksummed copy on a schedule to any S3-compatible storage. PostgreSQL uses database-native backup and PITR.

If the machine running bunqueue dies, a backup in object storage is how you get your jobs, cron schedules, and DLQ back. Backups are gzip-compressed and verified with a SHA256 checksum (a fingerprint of the data that proves the restore is byte-identical). ## Quick Start Set the environment variables and start the server. The first backup runs one minute after startup, then every 6 hours: ```bash BUNQUEUE_DATA_PATH=/var/lib/bunqueue/bunqueue.db S3_BACKUP_ENABLED=1 S3_ACCESS_KEY_ID=your-access-key S3_SECRET_ACCESS_KEY=your-secret-key S3_BUCKET=my-backups S3_REGION=us-east-1 S3_BACKUP_INTERVAL=21600000 # 6 hours (default) S3_BACKUP_RETENTION=7 # keep 7 backups (default) S3_BACKUP_PREFIX=backups/ # key prefix (default) ``` `BUNQUEUE_DATA_PATH` (or `BQ_DATA_PATH`, `DATA_PATH`, `SQLITE_PATH`) is required: an in-memory queue has no SQLite file to back up. The PostgreSQL driver is also outside this file-snapshot facility; use database-native backup/PITR. If backup is enabled without persistent SQLite, server startup fails before opening ports. Or configure it in `bunqueue.config.ts`: ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ storage: { dataPath: '/var/lib/bunqueue/bunqueue.db', }, backup: { enabled: true, bucket: 'my-backups', accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, region: 'us-east-1', interval: 21600000, retention: 7, prefix: 'backups/', }, }); ``` See [Configuration File](/guide/configuration/) for the full reference. AWS-style variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_BUCKET`, `AWS_REGION`, `AWS_ENDPOINT`) are also accepted as fallbacks. Temporary credentials can use `S3_SESSION_TOKEN`; `S3_VIRTUAL_HOSTED_STYLE=true` forces bucket-in-host addressing where a provider requires it. ## Backup and Restore from the CLI Backup commands run locally, not through the server: they read the database path from `BUNQUEUE_DATA_PATH` and the S3 credentials from the environment variables above. ```bash bunqueue backup now # create a backup right now bunqueue backup list # list backups in the bucket bunqueue backup status # show current configuration bunqueue backup restore -f # restore (overwrites the database) ``` :::caution[Restore safety] Restore requires the `--force` (`-f`) flag and **overwrites** the current database. Always stop the server before restoring: replacing the path cannot invalidate a SQLite handle that is already open. ::: ## Supported Providers Any S3-compatible storage works. Set `S3_ENDPOINT` for non-AWS providers: | Provider | Endpoint | | ------------------- | -------------------------------------------- | | AWS S3 | (default) | | Cloudflare R2 | `https://.r2.cloudflarestorage.com` | | MinIO | `http://localhost:9000` | | DigitalOcean Spaces | `https://.digitaloceanspaces.com` | ## How It Works Each backup cycle: 1. Flushes the server's pending SQLite write buffer; if storage backoff leaves any accepted write pending, the backup fails instead of publishing an incomplete snapshot 2. Uses SQLite `VACUUM INTO` to create a standalone, transactionally consistent snapshot (including committed WAL frames even when a reader pins the WAL) 3. Runs `PRAGMA integrity_check`, compresses the snapshot with gzip, and computes SHA256 over the uncompressed bytes 4. Uploads `.meta.json` first and the uniquely named `.db` payload second as the publication point, retrying transient errors with exponential backoff and a 30-second timeout per attempt 5. Deletes old payload/metadata pairs beyond the retention limit Only one backup runs at a time within one server/manager process; overlapping requests on that manager are rejected. The guard is not distributed, so do not run `bunqueue backup now` from the CLI while the server's scheduled manager is backing up the same database. On restore, bunqueue validates metadata and compressed size, decompresses the payload, verifies the original size and SHA256, validates the `SQLite format 3` header, and runs `PRAGMA integrity_check` on a temporary file. It quarantines stale `-wal`, `-shm`, and `-journal` files before atomically renaming the candidate over the live database, so old WAL frames cannot replay into the restored state. A pre-swap failure leaves the current database and its sidecars untouched. Older **uncompressed** backups without a metadata file remain restorable (checksum verification is unavailable). A compressed payload without metadata is rejected because it cannot be authenticated. ## Monitor Backup Freshness The Prometheus endpoint always exposes scheduled-backup state and initializes all values to zero before the first attempt. The most important signals are: ```text bunqueue_backup_scheduler_running bunqueue_backup_successes_total bunqueue_backup_failures_total bunqueue_backup_consecutive_failures bunqueue_backup_last_success_timestamp_seconds bunqueue_backup_last_duration_seconds bunqueue_backup_last_size_bytes ``` Use `time() - bunqueue_backup_last_success_timestamp_seconds` for backup age. The bundled alert rules page when an enabled scheduler is stopped, when no success occurs within two configured intervals, or when attempts fail. The counter invariant is: ```text attempts = successes + failures + (in_progress ? 1 : 0) ``` Overlap rejections are separate because they do not start a backup attempt. See [Monitoring](/guide/monitoring/#backup-metrics) for the complete metric list and dashboard panels. --- # Rate Limiting & Concurrency for Bun Job Queues Control job processing rates in bunqueue with per-queue rate limits and concurrency caps. Protect downstream services via CLI, SDK or MCP. URL: https://bunqueue.dev/guide/rate-limiting/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · rate limiting

Rate limits and concurrency, under control.

Cap how many jobs start per second, or how many run at the same time, so a busy queue never overwhelms the API or database behind it.

bunqueue gives you two independent knobs per queue: - **Rate limit**: how many jobs may *start* per second (throughput cap). - **Concurrency limit**: how many jobs may be *active at once* (parallelism cap). Neither is set by default, so queues run unlimited until you say otherwise. ## Set a rate limit Cap a queue at 100 jobs per second: ```bash bunqueue rate-limit set emails 100 # max 100 jobs/second bunqueue rate-limit clear emails # back to unlimited ``` Or from the SDK (works in both embedded and TCP mode): ```typescript queue.setGlobalRateLimit(100); // 100 jobs per second queue.setGlobalRateLimit(100, 60_000); // 100 jobs per minute queue.removeGlobalRateLimit(); // Awaitable variants: resolve after the server applied the change await queue.setGlobalRateLimitAsync(100, 60_000); await queue.removeGlobalRateLimitAsync(); ``` ```typescript await queue.setGlobalRateLimitAsync(100); // 100 jobs per second await queue.setGlobalRateLimitAsync(100, 60_000); // 100 jobs per minute await queue.removeGlobalRateLimitAsync(); // Awaitable variants: resolve after the server applied the change await queue.setGlobalRateLimitAsync(100, 60_000); await queue.removeGlobalRateLimitAsync(); ``` ```python queue.set_global_rate_limit(100) # 100 jobs per second queue.set_global_rate_limit(100, 60000) # 100 jobs per minute queue.remove_global_rate_limit() ``` ```php $queue->setRateLimit(100); // 100 jobs per second $queue->setRateLimit(100, 60000); // 100 jobs per minute $queue->clearRateLimit(); ``` ```go err := queue.SetRateLimit(100) // 100 jobs per second err = queue.SetRateLimit(100, bunqueue.RateLimitOptions{DurationMs: 60000}) // per minute err = queue.ClearRateLimit() ``` ```rust queue.set_rate_limit(100, None, None)?; // 100 jobs per second queue.set_rate_limit(100, Some(60_000), None)?; // 100 jobs per minute queue.clear_rate_limit()?; ``` ```elixir :ok = Bunqueue.Queue.set_rate_limit(queue, 100) # per second :ok = Bunqueue.Queue.set_rate_limit(queue, 100, duration: 60_000) # per minute :ok = Bunqueue.Queue.clear_rate_limit(queue) ``` The limit is a token bucket that refills continuously: `max` tokens spread over the `duration` window (default 1 second). ## Set a concurrency limit Cap a queue at 5 jobs running at the same time, across all workers: ```bash bunqueue concurrency set emails 5 bunqueue concurrency clear emails ``` ```typescript queue.setGlobalConcurrency(5); queue.removeGlobalConcurrency(); ``` ```typescript await queue.setGlobalConcurrencyAsync(5); await queue.removeGlobalConcurrencyAsync(); ``` ```python queue.set_global_concurrency(5) queue.remove_global_concurrency() ``` The PHP SDK does not expose this broker command yet. Configure the same server-side limit with the CLI; it still applies to PHP workers: ```bash bunqueue concurrency set emails 5 bunqueue concurrency clear emails ``` The Go SDK does not expose this broker command yet. Configure the same server-side limit with the CLI; it still applies to Go workers: ```bash bunqueue concurrency set emails 5 bunqueue concurrency clear emails ``` The Rust SDK does not expose this broker command yet. Configure the same server-side limit with the CLI; it still applies to Rust workers: ```bash bunqueue concurrency set emails 5 bunqueue concurrency clear emails ``` ```elixir :ok = Bunqueue.Queue.set_concurrency(queue, 5) :ok = Bunqueue.Queue.clear_concurrency(queue) ``` *The PHP, Go, and Rust SDKs do not expose the global concurrency helpers yet. The cap lives server-side per queue, so set it with `bunqueue concurrency set` or from any other client and it applies to workers in every language.* This is a *queue-level* cap. Each worker also has its own `concurrency` option that limits how many jobs that one worker runs in parallel: ```typescript const worker = new Worker('emails', processor, { concurrency: 5, // this worker runs at most 5 jobs at once }); ``` ```typescript const worker = new Worker('emails', processor, { concurrency: 5, // this worker runs at most 5 jobs at once }); ``` ```python worker = Worker("emails", process, concurrency=5) # at most 5 jobs at once ``` ```php // A PHP Worker is intentionally sequential. Run five worker processes when // this service should process up to five jobs in parallel. $worker = new Worker('emails', $processor); $worker->run(); ``` ```go worker := bunqueue.NewWorker("emails", processor, bunqueue.WorkerOptions{ Concurrency: 5, // this worker runs at most 5 jobs at once }) ``` ```rust let worker = Worker::new("emails", processor, WorkerOptions { concurrency: 5, // this worker runs at most 5 jobs at once ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("emails", processor, concurrency: 5) ``` *The PHP worker processes jobs sequentially by design and has no `concurrency` option; run more PHP worker processes to parallelize.* ## Custom time windows (per worker) The queue-level limit above already supports any window via the `duration` argument. If you instead want the cap enforced per single worker, use the `limiter` option: ```typescript const worker = new Worker('emails', processor, { limiter: { max: 100, duration: 60_000 }, // 100 jobs per minute, per worker }); ``` ```typescript const worker = new Worker('emails', processor, { limiter: { max: 100, duration: 60_000 }, // 100 jobs per minute, per worker }); ``` The Python SDK has no per-worker limiter. Use a custom queue-wide broker window instead: ```python queue.set_global_rate_limit(100, 60000) # 100 starts/min across all workers ``` The PHP SDK supports a custom window as a queue-wide broker limit: ```php $queue->setRateLimit(100, 60000); // 100 starts/min across all workers ``` The Go SDK supports a custom window as a queue-wide broker limit: ```go err := queue.SetRateLimit(100, bunqueue.RateLimitOptions{DurationMs: 60000}) ``` The Rust SDK supports a custom window as a queue-wide broker limit: ```rust queue.set_rate_limit(100, Some(60_000), None)?; ``` The Elixir SDK supports a custom window as a queue-wide broker limit: ```elixir :ok = Bunqueue.Queue.set_rate_limit(queue, 100, duration: 60_000) ``` The Bun worker limit is enforced client-side by each worker, so with 3 identical workers the effective rate is 3x. The budget counts **job starts**, not completions. Admission is synchronous at the processor dispatch boundary, so `concurrency: 20` cannot overshoot a `max: 2` window. Batch pulling is capped by the same remaining budget: jobs beyond those two starts stay `waiting` on the broker instead of being leased and parked inside the worker. The same gate applies to `getNextJob()` + `processJobManually()`. Manual processing waits until a start token is available before invoking the processor. When locks are enabled, `getNextJob()` exposes the broker lease on `job.token`, and `processJobManually(job)` reuses that tracked token if its explicit token argument is omitted. You can also apply a temporary worker-local pause dynamically: ```typescript worker.rateLimit(5_000); // do not start another job for at least five seconds ``` This override works with or without a configured `limiter`, including workers using `groupKey`, and never alters tokens already consumed by previous starts. *The worker `limiter` option is available in the Bun client. In the network SDKs, use the queue-level rate limit shown in each tab or a client-side limiter of your own in the processor.* ## Using AI agents? Agents connected via [MCP](/guide/mcp/) can set and clear both limits in natural language ("rate limit emails to 50 per second") through the `bunqueue_set_rate_limit`, `bunqueue_clear_rate_limit`, `bunqueue_set_concurrency`, and `bunqueue_clear_concurrency` tools. ## Reference | Control | Scope | Window | How | |---------|-------|--------|-----| | Rate limit | Queue (all workers) | Any duration (default 1s) | `bunqueue rate-limit set`, `queue.setGlobalRateLimit(max, duration?)` | | Concurrency limit | Queue (all workers) | n/a | `bunqueue concurrency set`, `queue.setGlobalConcurrency(n)` | | Worker concurrency | One worker | n/a | `new Worker(..., { concurrency })` | | Worker limiter | One worker | Rolling duration | `new Worker(..., { limiter: { max, duration } })` | | Temporary worker override | One worker | Explicit TTL | `worker.rateLimit(ms)` | ## Gotchas :::note[Older servers ignore the duration] Servers older than 2.8.35 ignore the `duration` argument and always use a 1-second window. Upgrade the server to get custom windows; the client remains compatible in both directions. ::: :::note[Temporary throttle expires server-side] `await queue.rateLimit(ms)` throttles the queue to ~1 job/sec and the **server** clears it after `ms` on its own, in embedded and TCP mode alike. It throws if `ms` is not a positive finite number. ::: For rate limiting that must be shared with code outside the queue (for example, an API budget also consumed by web requests), use an external limiter inside your processor: ```typescript const worker = new Worker('emails', async (job) => { await ratelimit.limit('email-send'); // external limiter, e.g. Upstash await sendEmail(job.data); }); ``` ```typescript const worker = new Worker('emails', async (job) => { await ratelimit.limit('email-send'); // external limiter, e.g. Upstash await sendEmail(job.data); }); ``` ```python def process(job): ratelimit.limit("email-send") # your external limiter send_email(job.data) worker = Worker("emails", process) ``` ```php $worker = new Worker('emails', function (Bunqueue\Job $job) use ($ratelimit) { $ratelimit->limit('email-send'); // your external limiter sendEmail($job->data()); }); ``` ```go worker := bunqueue.NewWorker("emails", func(job *bunqueue.Job) (any, error) { ratelimit.Limit("email-send") // your external limiter return sendEmail(job.Data()) }, bunqueue.WorkerOptions{}) ``` ```rust let worker = Worker::new( "emails", |job| { ratelimit.limit("email-send"); // your external limiter send_email(job.data()); Ok(Value::from(true)) }, WorkerOptions::default(), ); ``` ```elixir worker = Bunqueue.Worker.new("emails", fn job -> :ok = RateLimit.limit("email-send") # your external limiter send_email(job.data) {:ok, %{sent: true}} end) ``` :::tip[Related Guides] - [Queue API](/guide/queue/) - Queue configuration options - [Worker API](/guide/worker/) - Worker concurrency settings - [Environment Variables](/guide/env-vars/) - Protocol-level per-client request limiter (`RATE_LIMIT_*`), separate from queue rate limits ::: --- # Webhooks: Get Notified When Jobs Complete or Fail bunqueue sends HTTP callbacks on job events, signed with HMAC-SHA256 and retried automatically. No polling needed. URL: https://bunqueue.dev/guide/webhooks/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · webhooks

Job events, delivered as webhooks.

A webhook is an HTTP POST that bunqueue sends to your URL when something happens to a job. Instead of polling for status, your service gets told, with a signed payload and automatic retries.

## Quick Start Register a URL and pick the events you care about (`--events` is required): ```bash bunqueue webhook add https://api.example.com/hooks/bunqueue \ --events job.completed,job.failed --secret my-webhook-secret ``` Then receive the POSTs. A minimal Bun server: ```typescript Bun.serve({ port: 3000, async fetch(req) { const payload = await req.json(); console.log(`${payload.event} for job ${payload.jobId} on ${payload.queue}`); return Response.json({ received: true }); }, }); ``` That is enough to see events flowing. In production, always verify the signature first (see below). ## Common Tasks ### Manage webhooks from an SDK The TypeScript, Python, PHP, and Go SDKs expose the same webhook surface programmatically: The Bun package has no typed webhook helpers yet. Manage webhooks with the CLI above, the HTTP API, or the raw `AddWebhook` / `ListWebhooks` / `RemoveWebhook` TCP commands. ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('emails'); // Registered webhooks are scoped to this queue unless you pass `queue` const { webhookId } = await queue.addWebhook({ url: 'https://api.example.com/hooks/bunqueue', events: ['job.completed', 'job.failed'], secret: 'my-webhook-secret', // optional }); const hooks = await queue.listWebhooks(); await queue.setWebhookEnabled(webhookId, false); // pause delivery await queue.removeWebhook(webhookId); ``` ```python from bunqueue import Queue queue = Queue("emails") # queue=None registers a global webhook; pass queue="emails" to scope it webhook_id = queue.add_webhook( "https://api.example.com/hooks/bunqueue", ["job.completed", "job.failed"], secret="my-webhook-secret", # optional ) hooks = queue.list_webhooks() queue.set_webhook_enabled(webhook_id, False) # pause delivery queue.remove_webhook(webhook_id) ``` ```php use Bunqueue\Queue; $queue = new Queue('emails'); $webhookId = $queue->addWebhook( 'https://api.example.com/hooks/bunqueue', ['job.completed', 'job.failed'] ); $hooks = $queue->listWebhooks(); $queue->setWebhookEnabled($webhookId, false); // pause delivery $queue->removeWebhook($webhookId); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{}) webhookID, _ := queue.AddWebhook( "https://api.example.com/hooks/bunqueue", []string{"job.completed", "job.failed"}, ) hooks, _ := queue.ListWebhooks() _ = queue.SetWebhookEnabled(webhookID, false) // pause delivery _ = queue.RemoveWebhook(webhookID) ``` The Rust SDK has no typed webhook helpers yet. Manage webhooks with the CLI above, the HTTP API, or the raw `AddWebhook` / `ListWebhooks` / `RemoveWebhook` TCP commands. The Elixir SDK has no typed webhook helpers yet. Manage webhooks with the CLI above, the HTTP API, or the raw `AddWebhook` / `ListWebhooks` / `RemoveWebhook` TCP commands. *The PHP and Go helpers register global, unsigned webhooks (`url` + `events` only); use the CLI or HTTP API to set a `secret` or a queue scope from those languages.* ### Scope a webhook to one queue ```bash bunqueue webhook add https://api.example.com/hooks/emails \ --events job.completed,job.failed --queue emails ``` ### List and remove webhooks ```bash bunqueue webhook list # 01920b5e-7c4a-...: https://api.example.com/hooks/bunqueue # Events: job.completed, job.failed # Delivered: 42 ok / 0 failed bunqueue webhook remove 01920b5e-7c4a-7000-8a3e-2f9d1c4b6e10 ``` Each entry starts with the webhook ID; that ID is what `remove` takes. Disabled webhooks are marked `[disabled]`. ### Temporarily disable a webhook Toggling is not a CLI command. Use the SDK helpers shown above (`setWebhookEnabled` / `set_webhook_enabled` / `SetWebhookEnabled`), the TCP command `SetWebhookEnabled`, the HTTP API, or the MCP tool: ```text bunqueue_set_webhook_enabled({ id: "01920b5e-...", enabled: false }) ``` Disabling stops delivery but keeps the configuration, useful during maintenance. ## Event Types These five events are the only valid ones; anything else is rejected at registration time: | Event | When it fires | |-------|---------------| | `job.pushed` | Job added to a queue | | `job.started` | A worker picked the job up | | `job.completed` | The worker finished successfully | | `job.failed` | The worker threw an error | | `job.progress` | The processor called `job.updateProgress()` | ## Payload Every delivery is a JSON POST with three headers: `X-Webhook-Event`, `X-Webhook-Timestamp`, and `X-Webhook-Signature` (only when a secret is set). ```json { "event": "job.completed", "timestamp": 1704067200000, "jobId": "1001", "queue": "emails", "data": { "sent": true } } ``` All payloads carry `event`, `timestamp`, `jobId`, and `queue`. The rest depends on the event: - `job.completed`: `data` is the **result** returned by the worker - `job.failed`: `data` is the job's input data, plus an `error` message - `job.progress`: carries a `progress` number instead of `data` - `job.pushed` and `job.started`: base fields only ## Verifying Signatures When a webhook is registered with `--secret`, bunqueue signs each payload with HMAC-SHA256 (a keyed hash: only someone who knows the secret can produce a valid signature). The signature is the hex-encoded HMAC of the raw JSON body. Verify it before trusting the request; the receiver can be written in any language: ```typescript import { createHmac, timingSafeEqual } from 'crypto'; function verifySignature(rawBody: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } Bun.serve({ port: 3000, async fetch(req) { const signature = req.headers.get('x-webhook-signature'); const rawBody = await req.text(); if (!signature || !verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) { return Response.json({ error: 'Invalid signature' }, { status: 401 }); } const payload = JSON.parse(rawBody); // process payload ... return Response.json({ received: true }); }, }); ``` ```typescript import { createHmac, timingSafeEqual } from 'node:crypto'; function verifySignature(rawBody: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } // In your HTTP handler: read the RAW body first, verify, then JSON.parse it. ``` ```python import hashlib import hmac def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) # In your HTTP handler: read the RAW body first, verify, then json.loads it. ``` ```php function verifySignature(string $rawBody, string $signature, string $secret): bool { $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $signature); } // Read the raw body with file_get_contents('php://input'), verify, then json_decode it. ``` ```go import ( "crypto/hmac" "crypto/sha256" "encoding/hex" ) func verifySignature(rawBody []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } // In your HTTP handler: read the RAW body first, verify, then json.Unmarshal it. ``` ```rust // requires the hmac, sha2 and hex crates use hmac::{Hmac, Mac}; use sha2::Sha256; fn verify_signature(raw_body: &[u8], signature: &str, secret: &str) -> bool { let Ok(sig) = hex::decode(signature) else { return false; }; let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("any key length"); mac.update(raw_body); mac.verify_slice(&sig).is_ok() // constant-time comparison } // In your HTTP handler: read the RAW body first, verify, then deserialize it. ``` ```elixir # requires {:plug_crypto, "~> 2.0"} for the constant-time comparison def verify_signature(raw_body, signature, secret) do expected = :crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower) Plug.Crypto.secure_compare(expected, signature) end # In your HTTP handler: read the RAW body first, verify, then decode it. ``` Two things matter in any language: compute the HMAC over the **raw request body** (not a re-serialized object), and compare with a constant-time function (`timingSafeEqual` in Node/Bun, `hmac.compare_digest` in Python, `hash_equals` in PHP, `hmac.Equal` in Go, `Mac::verify_slice` in Rust, `Plug.Crypto.secure_compare` in Elixir). :::caution[Unsigned webhooks] Without `--secret`, payloads are sent unsigned and anyone can forge requests to your endpoint. Always set a secret in production. ::: ## Delivery and Retries A delivery succeeds when your endpoint returns a 2xx status within 10 seconds. Failed deliveries are retried with linear backoff: | Attempt | Delay | |---------|-------| | 1 | Immediate | | 2 | 1 second | | 3 | 2 seconds | The totals are configurable with `WEBHOOK_MAX_RETRIES` (default 3 attempts) and `WEBHOOK_RETRY_DELAY_MS` (default 1000). After all attempts, the delivery is abandoned and logged. Webhook failures never affect job processing. Because of retries, the same event can arrive twice. Deduplicate on `jobId` + `event` if that matters to your handler. Also return 2xx quickly and do heavy work asynchronously, so slow processing does not get counted as a failed delivery. ## Gotchas - **SSRF protection:** URLs pointing to localhost, private or link-local IP ranges, or cloud metadata endpoints are rejected at registration time. Only `http:`/`https:` URLs up to 2048 characters are accepted. This means you cannot register a webhook to a local dev server from the same machine. - **Missing events:** check the `--events` and `--queue` filters, and remember `job.progress` only fires when a processor calls `job.updateProgress()`. - **Nothing delivered:** run `bunqueue webhook list` and look at the `Delivered: N ok / N failed` counters and the `[disabled]` marker; delivery failures are also logged by the server with the target URL. - **Invalid signature errors:** verify against the raw body, make sure no proxy rewrites the payload, and confirm both sides use the same secret. :::tip[Related Guides] - [Queue API](/guide/queue/) - In-process events without HTTP - [Environment Variables](/guide/env-vars/) - Webhook retry configuration - [Server Mode](/guide/server/) - Webhooks require server mode ::: --- # Engineering Benchmarks: Queue and Workflow Engine Native, reproducible bunqueue benchmarks for embedded and TCP queue operations, durable writes, worker drain, workflow orchestration and latency. URL: https://bunqueue.dev/guide/benchmarks/ import { Aside } from '@astrojs/starlight/components';
native engineering campaign · 2026-07-30

Benchmarks measured,
not mixed.

Every number names the operation, persistence boundary, topology, scale, sample count and integrity check. A public on-disk add is not an internal in-memory batch, and producer ingestion is not worker drain.

729K jobs/sec internal in-memory batch push 159K jobs/sec TCP PUSHB, on-disk broker 3.2K linear workflows/sec, one TCP Engine
## Read the label before the number The current campaign ran natively on an AMD Ryzen 9 9950X3D (16 cores / 32 threads), 59 GiB RAM, Linux 7.0.0-14, CPU governor `performance`, Bun 1.3.14, revision `af027f04d2064b701ee2243eac737d74b8d87706`. Docker was used only for the functional sandbox and was stopped before performance measurement. These labels are not interchangeable: | Label | What it means | | ------------------ | ----------------------------------------------------------------------------- | | Internal in-memory | Direct `QueueManager` batch API, no `dataPath`, no SQLite write | | Embedded on-disk | Public `Queue` API and a fresh SQLite `dataPath` | | TCP on-disk | Public client over localhost MessagePack to a broker with its own SQLite file | | Buffered | Normal write-behind persistence, with the documented ≤10 ms hard-crash window | | Durable (SQLite) | `durable:true`; the timed call waits for synchronous SQLite persistence | | Workflow | A complete multi-node execution, not one queue operation | ## Queue engine ### Representative throughput All rows below are medians. They describe different workloads and should not be divided into an “Embedded is N× TCP” claim. | Operation | Samples | Median | Variability / tail | Integrity | | ------------------------------------------- | ------: | -----------------: | -------------------------------: | ------------------------ | | Internal in-memory batched push, 1M jobs | 21 | **729,395 jobs/s** | 693,001–747,943; CV 1.94% | 21M/21M IDs and payloads | | Internal in-memory batched process, 1M jobs | 21 | **541,712 jobs/s** | 523,286–558,036; CV 1.40% | pull + completed event | | Internal in-memory full lifecycle, 1M jobs | 21 | **311,915 jobs/s** | 303,674–316,556; CV 1.21% | clean final state | | TCP pipelined individual push | 21 | **80,978 ops/s** | 78,331–83,806; CV 2.06% | fresh broker/database | | TCP `PUSHB`, 50K jobs | 21 | **158,779 jobs/s** | 146,496–163,501; CV 3.25% | fresh broker/database | | TCP no-work worker drain, concurrency 50 | 21 | **17,256 jobs/s** | p05 16,455; p95 17,864; CV 2.86% | 420K/420K exactly once | The 1M-job runner uses 16 queues and workers, removes completed jobs, and checks the complete ID set both when pulled and on the completed event. It is the engine's maximum-throughput path, not the public Queue API. ### Public API with on-disk SQLite `bench:pushbulk` ran eight fresh campaigns; the first was discarded. Each cell is already a median of three repetitions. This is the median of seven campaign medians for the final 50K cell: | Mode | `add()` | `addBulk()` | | ----------------- | -----------------: | -----------------: | | Embedded, on-disk | **147,818 jobs/s** | **186,384 jobs/s** | | TCP, on-disk | **127,476 jobs/s** | **87,319 jobs/s** | This runner grows one database through 1K, 5K, 10K and 50K cells. The 50K row is deliberately a sustained/grown-database result. The isolated 159K TCP `PUSHB` row above is the clean-database bulk result. ### Latency and synchronous durability After the TCP throughput campaign, 5,000 sequential localhost adds measured **14 µs p50 / 292 µs p99**. For SQLite `durable:true`, every fresh process timed 2,000 sequential operations after 50 warm-ups: | Mode | Runs | Throughput median | p05 / p95 | Latency p50 / p95 / p99 | | -------- | ---: | ----------------: | --------------: | ----------------------: | | Embedded | 21 | **60,835 ops/s** | 53,698 / 63,002 | 15 / 25 / 59 µs | | TCP | 21 | **27,191 ops/s** | 21,590 / 29,089 | 30 / 57 / 120 µs | All 43,050 operations per mode, including warm-ups, were present in queue counts. ### Worker concurrency A single TCP worker drained 10K preloaded jobs from a fresh broker/database in each sample: | Concurrency | Median drain | | ----------: | ----------------: | | 32 | **17,607 jobs/s** | | 48 | 17,541 jobs/s | | 64 | 17,191 jobs/s | | 96 | 15,830 jobs/s | | 128 | 15,094 jobs/s | | 192 | 14,081 jobs/s | The useful knee for this localhost no-work processor is 32–48. More concurrency is slower. Producer ingestion and worker drain are separate capacity limits. ## PostgreSQL 15–18 multi-broker The 2026-08-26 compatibility campaign ran natively on an Apple M1 Max with Bun 1.4.0 and PostgreSQL 15.19, 16.15, 17.11, and 18.6. Each of the 12 version/topology cells used one discarded warm-up and seven measured samples, with a fresh cluster, database, broker set, ports, namespace, and queue per sample. The campaign accepted, invoked, and completed all 840,000 measured IDs exactly once, with zero duplicate invocations, deadlocks, failed samples, or temporary-file spills. | Fixed 16-consumer topology | Lifecycle median range across PostgreSQL 15–18 | | -------------------------- | ---------------------------------------------: | | One broker | 6,550–6,945 jobs/s | | Two brokers | 8,004–8,494 jobs/s | | Four brokers | 7,168–7,788 jobs/s | PostgreSQL 18.6 led admission in every topology and the one/four-broker lifecycle medians. PostgreSQL 15.19 led the two-broker lifecycle median by 0.7%, inside overlapping 95% confidence intervals. Topology had a larger effect than server major: two brokers beat one on every version, while four added broker availability but introduced enough shared-database contention to trail two on this fixed workload. A separate PostgreSQL 18.6 bottleneck campaign found that four brokers with batch 250, pool size four, and 250 ms polling raised lifecycle median from 7,478 to 8,362 jobs/s versus batch 100. The gain came with higher per-command p95, 31.9% more WAL per job, and temporary spill in every measured batch-250 sample. It is a throughput/latency/resource trade-off, not a universal default. - [Full PostgreSQL 15–18 methodology and results](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/postgres-versions-2026-08-26.md) - [PostgreSQL 18 bottleneck, pool, batch, WAL and `work_mem` analysis](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/postgres-performance-analysis-2026-08-26.md) - [Machine-readable evidence manifest](/benchmarks/postgres/2026-08-26/manifest.json) - [Raw 15–18 compatibility samples](/benchmarks/postgres/2026-08-26/postgres-versions-2026-08-26T18-13-50.637Z.json) The manifest publishes all seven exact JSON artifacts and their SHA-256 hashes. These are native engineering measurements, not capacity guarantees: reproduce them on the production CPU, storage, network, payload, retention, and broker mix. ## Workflow Engine `bun run bench:workflow` uses the public `Engine` facade and four graphs: | Scenario | Graph | Required terminal state | | ------------ | ---------------------------------------- | -------------------------------------------- | | Linear | validate → transform → persist | completed; 3 steps | | Parallel | prepare → 3 inline parallel steps → join | completed; 5 steps | | Compensation | reserve → charge → intentional failure | failed; rollback completed; 2 reversals | | Signal | request → wait for approval → finish | waited and signalled exactly once; completed | Every sample has a new process, queue, workflow SQLite file, and name. TCP adds a new broker process, broker database, and dynamic ports. The final tuning sweep selected Embedded concurrency 128 and TCP concurrency 64. ### Single-engine saturation | Mode / scenario | Runs × executions | Median | p05 / p95 | CV | Run-median p95 latency | | --------------------- | ----------------: | -------------: | ------------: | ----: | ---------------------: | | Embedded linear | 21 × 1,000 | **2,700 wf/s** | 2,570 / 2,792 | 2.86% | 342.8 ms | | Embedded parallel | 7 × 500 | **2,118 wf/s** | 1,997 / 2,173 | 2.70% | 219.8 ms | | Embedded compensation | 7 × 500 | **2,055 wf/s** | 2,005 / 2,118 | 2.05% | 225.6 ms | | Embedded signal | 7 × 500 | **1,928 wf/s** | 1,883 / 1,961 | 1.39% | 84.7 ms resume | | TCP linear | 21 × 1,000 | **3,187 wf/s** | 3,075 / 3,261 | 1.77% | 284.3 ms | | TCP parallel | 7 × 500 | **2,456 wf/s** | 2,368 / 2,528 | 2.48% | 186.4 ms | | TCP compensation | 7 × 500 | **2,239 wf/s** | 2,218 / 2,384 | 2.87% | 200.9 ms | | TCP signal | 7 × 500 | **2,234 wf/s** | 2,204 / 2,306 | 1.72% | 64.9 ms resume | These are saturated-batch `workflow:started`-event-to-terminal latencies, including time behind other executions after the lifecycle event begins. Throughput starts before the first `Engine.start()` call. Neither metric is idle single-workflow service latency. TCP can be faster here because the broker and Workflow Store run in separate processes with separate SQLite files. Embedded uses two connections to the same file. The result does not say that network transport is cheaper. Signal phase medians: | Mode | Park | Resume | Park p95 | Resume p95 | | -------- | ---------: | ---------: | -------: | ---------: | | Embedded | 3,205 wf/s | 4,837 wf/s | 139.1 ms | 84.7 ms | | TCP | 3,518 wf/s | 6,149 wf/s | 123.0 ms | 64.9 ms | Across both modes the measured single-engine campaign reconciled 63,000 executions, 189,000 step completions, 56,000 successful workflows, 7,000 intentional failures and 14,000 compensation outcomes. ### The default TCP safety cap The broker protects each protocol client with a default 10,000-request, 60-second sliding window. A workflow expands into multiple queue commands: | Linear executions | Default result | | ----------------: | ------------------------------: | | 3,000 | 4,005 wf/s; 0.749 s | | 3,500 | 58 wf/s; 60.159 s; p95 60.088 s | At the plateau, persisted state showed 896 workflows waiting for their last node, 896 broker jobs waiting and 34 active. At window turnover, ACK batches reported `Rate limit exceeded`, then every workflow completed. Lowering TCP frame timeout, command timeout or worker cleanup to 5 seconds did not move the plateau. Raising only `RATE_LIMIT_MAX_REQUESTS=1000000` restored 3,500 executions to a **3,855 wf/s** median across three fresh runs. That is why default and tuned results are separate. In production, size the protocol limit from expected command expansion and monitor rate-limit hits. ### Horizontal tuned capacity The scale runner launched independent engines behind a sub-millisecond common barrier: 5,000 executions per instance, one discarded warm-up and three measured campaigns. TCP used `RATE_LIMIT_MAX_REQUESTS=1000000`. | Mode / instances | Median | Speedup | Efficiency | Peak sampled CPU / RSS | | ---------------- | --------------: | ------: | ---------: | ---------------------: | | Embedded ×1 | 3,194 wf/s | 1.00× | 100% | 77% / 233 MiB | | Embedded ×4 | 10,579 wf/s | 3.31× | 82.8% | 317% / 894 MiB | | Embedded ×8 | 19,379 wf/s | 6.07× | 75.8% | 652% / 1,745 MiB | | Embedded ×12 | **25,873 wf/s** | 8.10× | 67.5% | 1,028% / 2,549 MiB | | TCP ×1 | 4,207 wf/s | 1.00× | 100% | 156% / 352 MiB | | TCP ×4 | 11,738 wf/s | 2.79× | 69.8% | 671% / 1,371 MiB | | TCP ×8 | 17,407 wf/s | 4.14× | 51.7% | 1,497% / 2,626 MiB | | TCP ×12 | **17,496 wf/s** | 4.16× | 34.7% | 2,415% / 3,861 MiB | TCP is effectively saturated at ×8 on this shared host. The runner checked 750,000 measured plus 250,000 warm-up workflow executions; every child passed the same integrity scan. ## Maximum host scale for queue jobs The separate queue-engine scale test used much larger internal workloads: | Topology | Median aggregate | Correctness | Peak resources | | -------------------------------- | -----------------------------: | ----------: | ---------------------------------- | | 12 Embedded instances × 1M jobs | **2,008,704 jobs/s** lifecycle | 36M / 36M | ~2,513% CPU; 37.4 GiB RSS; no swap | | 12 TCP broker/client pairs × 50K | **46,937 jobs/s** drain | 1.8M / 1.8M | ~2,249% CPU; 5.2 GiB RSS | The Embedded integrity sets intentionally retain 12 million IDs at once and dominate RSS. TCP gained about 2.7× over one pair, not 12×, because every pair shares one scheduler, loopback stack and storage device. ## Reproduce ```bash git clone https://github.com/egeominotti/bunqueue.git cd bunqueue bun install # Core queue runners bun run src/benchmark/million-jobs.bench.ts BENCH_N=50000 BENCH_RUNS=21 bun run bench:tcp # PostgreSQL 15–18, using native installations resolved by the runner BUNQUEUE_PG_BENCH_POOL_SIZE=12 \ BUNQUEUE_PG_BENCH_POLL_INTERVAL_MS=25 \ BUNQUEUE_PG_BENCH_WORK_MEM=4MB \ bun run bench:postgres:versions # The published push/bulk Embedded row is on-disk. Use a fresh path and point # BENCH_HOST/BENCH_PORT at a separately started fresh SQLite broker. BUNQUEUE_DATA_PATH=/tmp/bunqueue-bench-embedded.db \ BENCH_HOST=127.0.0.1 BENCH_PORT=16794 \ bun run bench:pushbulk # Workflow Engine, both modes and all scenarios BENCH_OUTPUT=/tmp/workflow.json bun run bench:workflow # Tuned host scale RATE_LIMIT_MAX_REQUESTS=1000000 \ BENCH_OUTPUT=/tmp/workflow-scale.json \ bun run bench:workflow:scale ``` Run without `BUNQUEUE_EMBEDDED=1`; that test variable forces clients into Embedded mode and would invalidate a TCP label. The bare `bun run bench:pushbulk` command does not configure an Embedded `dataPath`; its Embedded half is in-memory. It must not be used to reproduce an on-disk row unless `BUNQUEUE_DATA_PATH` points to a fresh SQLite file. The TCP half likewise requires a fresh broker/database at the explicitly reported endpoint. The runners and report are in the repository: - [`bench/workflow-engine.ts`](https://github.com/egeominotti/bunqueue/blob/main/bench/workflow-engine.ts) - [`bench/workflow-engine/scale.ts`](https://github.com/egeominotti/bunqueue/blob/main/bench/workflow-engine/scale.ts) - [`docs/benchmarks/native-engineering-2026-07-30.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/native-engineering-2026-07-30.md) - [`docs/benchmarks/native-engineering-2026-08-02.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/native-engineering-2026-08-02.md) - [`docs/benchmarks/postgres-versions-2026-08-26.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/postgres-versions-2026-08-26.md) - [`docs/benchmarks/postgres-performance-analysis-2026-08-26.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/postgres-performance-analysis-2026-08-26.md) - [Published PostgreSQL raw-evidence manifest](/benchmarks/postgres/2026-08-26/manifest.json) - [`docs/features/benchmarks.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/features/benchmarks.md) ## Functional evidence Before native measurement, the isolated sandbox passed 6,250 unit/model tests, 430 TCP integration tests and 273 Embedded integration tests, with zero failures. A mixed-suite memory-growth signal was followed by three focused TCP chaos soaks: 90K jobs, 81 worker kill/reconnect cycles, flat post-cold p99, bounded 4.1–4.3 MB WAL and post-GC collection bounds all passed. ## Historical BullMQ comparison The July 8 Apple M1 Max comparison remains available as a dated, separate campaign: bunqueue TCP bulk push 85,700 jobs/s versus BullMQ 24,800, while individual concurrent push was 52,756 versus 56,736. It used `bench/comparison/run.ts`, BullMQ 5.79.3 and Redis 8.8.0. Do not combine those values with the Ryzen campaign above. Read [bunqueue vs BullMQ](/guide/comparison/) or the [original benchmark article](/blog/benchmarks-vs-bullmq/). --- # BullMQ on Bun: Compatibility & SQLite Alternative Does BullMQ work on Bun? Yes, and bunqueue removes the Redis requirement: honest benchmarks, feature differences, and when BullMQ is the better pick. URL: https://bunqueue.dev/guide/comparison/ import { Aside } from '@astrojs/starlight/components'; import BqIcon from '@components/BqIcon.astro';
guide · comparison

bunqueue vs BullMQ, one less server.

Benchmark results comparing the bunqueue TCP server against BullMQ with Redis on identical workloads, a feature comparison, and the cases where BullMQ is still the better pick.

This page helps you decide between bunqueue and BullMQ. Short version first, then the numbers with their caveats, then features. ## Quick answer Pick **bunqueue** if you run on Bun and want embedded/single-broker SQLite or a PostgreSQL 15–18 multi-broker server without Redis; 18.6 is recommended. You get the same Queue and Worker API across these server backends. Pick **BullMQ** if Redis/Redis Cluster is already your operational standard, you need Redis-specific behavior, or you cannot run a Bun broker. Details in [When to Use BullMQ Instead](#when-to-use-bullmq-instead). ## The numbers

1.3x Faster Push

54,140 vs 43,261 ops/sec

Single job push

3.2x Faster Bulk

139,200 vs 44,000 ops/sec

Bulk push, 100 jobs per batch

Lower Bulk Latency

3.26 vs 4.53 ms p99

Bulk push tail latency

Zero Infrastructure

No Redis required

Embedded SQLite. One direct runtime dependency.

| Operation | bunqueue | BullMQ | Speedup | | ----------------------------- | --------------- | -------------- | -------------- | | **Push** (single job) | 54,140 ops/sec | 43,261 ops/sec | **1.3x** | | **Bulk push** (100 per batch) | 139,200 ops/sec | 44,000 ops/sec | **3.2x** | | **Bulk push p99 latency** | 3.26 ms | 4.53 ms | **1.4x lower** | :::note[Read this before quoting numbers] Benchmark figures depend on hardware and library versions, and they change across bunqueue versions. Two things to know: 1. A more recent run (2026-07-08, Apple M1 Max, Bun 1.3.14, BullMQ 5.79.3, Redis 8.8.0) measured **bulk push at 85,700 vs 24,800 ops/sec (3.5x)** with **single push at parity**. It is summarized on the [home page](/) with full methodology. 2. This page does not quote a Process (end-to-end job consumption) figure. Older published Process numbers predate a worker-leasing correctness fix in 2.8.18 that bounds how many jobs one TCP worker can lease. A single worker at `concurrency: 50` now measures in the thousands of ops/sec, and end-to-end throughput scales with worker count, not queue depth. See the [benchmarks page](/guide/benchmarks/) for current numbers. Reproduce either run with `bun run bench/comparison/run.ts`. All bunqueue figures on this page measure the memory/SQLite engine. PostgreSQL 18.6 functional tests are not benchmarks and these numbers must not be applied to the multi-broker backend. :::

Push ops/sec, higher is better

bunqueue
54,140
BullMQ
43,261

1.3x faster

Bulk push ops/sec, higher is better

bunqueue
139,200
BullMQ
44,000

3.2x faster

Bulk push p99 ms, lower is better

bunqueue
3.26 ms
BullMQ
4.53 ms

1.4x lower

**Environment for the table above:** Mac Studio (Apple M1 Max, 32 GB, SSD), macOS Tahoe, Bun 1.3.8, Redis 7.x on localhost. 10,000 iterations per test, bulk size 100, payload 100 bytes, 32-connection pool with pipelining. ## Why bunqueue is faster on these workloads - **TCP pipelining**: many commands in flight per connection, responses matched by request id. - **Batched writes**: SQLite in WAL mode (write-ahead logging, a journal that lets reads and writes overlap) groups many jobs into one disk transaction. - **Sharding**: work is spread across independent shards sized to your CPU cores, which keeps lock contention low. - **In-memory hot path**: the ready queue lives in memory (skip lists, heaps, LRU caches), SQLite is the durability layer, not the dispatcher. ## Feature comparison | Feature | bunqueue | BullMQ | | ----------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------ | | Queue types (standard, priority, LIFO) | ✅ | ✅ | | Delayed jobs, retries with backoff | ✅ | ✅ | | Dead letter queue (holding area for jobs that failed all retries) | ✅ Built-in, with auto-retry and expiration | ⚠️ Failed set, no dedicated DLQ | | Rate limiting | ✅ Per-worker and per-queue | ✅ Per-worker | | Cron / repeatable jobs | ✅ Built-in | ✅ Via Job Scheduler | | Parent-child flows | ✅ | ✅ | | Pro-style job groups | ✅ Priority/FIFO, max size, pause, limits | ✅ BullMQ Pro | | Native processor batches | ✅ Bun client, selective member failure | ✅ BullMQ Pro | | AbortSignal / Observable processors | ✅ Bun client | ✅ BullMQ Pro | | Pro telemetry / NestJS integration | ❌ Use native metrics; framework-neutral | ✅ BullMQ Pro | | Persistence | Memory/SQLite by default; PostgreSQL 15–18 optional | Redis server | | Horizontal broker scaling | ✅ PostgreSQL mode | ✅ Redis Cluster | | External infrastructure | ✅ None with SQLite; PostgreSQL optional | ❌ Redis required | | Built-in S3 backup | ✅ SQLite mode | ❌ Manual | | MCP server for AI agents | ✅ Built-in | ❌ | | Client languages | TypeScript, Python, PHP, Go, Rust, Elixir | Node.js, Python, and community ports | ## When to Use BullMQ Instead bunqueue's SQLite mode is single-broker; its PostgreSQL mode supports multiple brokers. BullMQ is still the better pick when: - **Redis Cluster is a hard requirement** or already provides your queue HA model. - **Redis is already in your stack** and operating it costs you nothing extra. - **You need Redis-specific features** such as pub/sub fan-out or custom Lua scripts. - **Your workers are written in languages** outside bunqueue's official SDKs. - **You cannot run on Bun.** bunqueue's server and embedded mode require the Bun runtime. ## Run it yourself ```bash git clone https://github.com/egeominotti/bunqueue.git cd bunqueue && bun install redis-server --daemonize yes # BullMQ side bun run start & # bunqueue server bun run bench/comparison/run.ts ``` Source: [`bench/comparison/run.ts`](https://github.com/egeominotti/bunqueue/blob/main/bench/comparison/run.ts). --- # bunqueue in Production: What Survives a Crash Plain answers to what happens when a worker or the server dies mid-job: at-least-once delivery, durable writes, backups, monitoring, and a go-live checklist. URL: https://bunqueue.dev/guide/production/ import { Aside } from '@astrojs/starlight/components';
guide · production

What survives a crash.

Run the default engine as one process plus SQLite, or run multiple brokers against PostgreSQL 15–18. PostgreSQL 18.6 is the pinned and recommended release. This page explains crash behavior, monitoring, and the storage-specific checks before go-live.

quick answers

If it dies, what happens?

The short version first. These rows describe the default SQLite path; PostgreSQL differences follow.

| Failure | What happens to the job | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Worker crashes mid-job | The job is redelivered to another worker after its lease expires. It may run twice, never zero times. | | Server killed hard (`kill -9`) | Jobs written to disk survive and recover on restart. Jobs accepted in the last ~10 ms may be lost, unless they were added with `durable: true`. | | Server restarted gracefully (SIGTERM, deploys) | After shutdown completes successfully, buffered SQLite writes are flushed before exit. A flush error or forced termination follows the hard-kill row instead. | | Job fails repeatedly | After its retry attempts are exhausted it moves to the DLQ (dead letter queue, a parking lot for failed jobs you can inspect and retry). See [DLQ](/guide/dlq/). | | Disk fills up | The server stays up and `/health` reports `degraded` with HTTP 503. Durable pushes fail explicitly instead of pretending to succeed. | | The whole machine is lost | You restore the last S3 snapshot. Your maximum data loss is the backup interval. |
the crash timeline

The worker dies before the ACK.

This is the failure that defines a queue's honesty. Here is the exact sequence.

1. A worker pulls a job. The server marks it active and gives the worker a **lease**: a lock with a time-to-live (default 30 seconds, the worker's `lockDuration`), renewed by heartbeats while the job runs. 2. Your handler does its side effect. The email is sent, the charge is captured. 3. The worker dies **before its ACK reaches the server**. An ACK is the worker's "done" confirmation; without it the server has no idea the work happened. 4. The lease expires and the server requeues the job with its attempt count incremented. 5. Another worker pulls it. **Your side effect runs again.** Once a job is durably admitted, the processing guarantee is **at-least-once**: it can run more than once, but the broker does not silently discard that generation. SQLite's default write buffer precedes durable admission, so a hard crash can still lose jobs accepted inside its documented 10 ms window unless they use `durable: true`. No queue can atomically combine your external side effect with its own acknowledgment, so a crash can always land between them. What bunqueue guarantees around those edges: - A late ACK after ordinary lease expiry is accepted only while that exact generation still owns the processing slot and the job was not re-leased. If the job's processing `timeout` already finalized that generation, the ACK/FAIL is instead an explicit idempotent no-op. Two generations can never both complete the same job. - Re-adding a job with the same custom `jobId` is a no-op, even under heavy concurrency. Producers that retry on timeout should always set one. - Duplicate ACKs are harmless.
durability

Two write modes, one decision.

By default bunqueue batches writes to SQLite every 10 ms for throughput. A durable write skips the application batch and commits before the add returns; host and physical-media durability remain operational concerns.

| SQLite mode | Published native workload median | Bunqueue process-crash buffer window | | ----------------------- | ---------------------------------------: | ------------------------------------ | | Buffered (default) | 186,384 jobs/s, public on-disk `addBulk` | up to 10 ms of accepted jobs | | `durable: true` per job | 60,835 ops/s, sequential Embedded adds | none after `add()` resolves | ```typescript await queue.add('send-newsletter', data); // buffered, fast await queue.add('capture-payment', data, { durable: true }); // committed before this returns ``` Mixing is free: mark only the jobs that are money as durable and keep the rest batched. The 10 ms window applies to abrupt process termination; a successfully completed graceful shutdown flushes the buffer. Measured numbers per mode are in [Benchmarks](/guide/benchmarks/).
backup and restore

One file, snapshotted to S3.

In SQLite mode, the entire queue state is one database file, so disaster recovery is one object-storage snapshot. Works with AWS S3, Cloudflare R2, and MinIO; PostgreSQL uses database-native backup and PITR.

```bash S3_BACKUP_ENABLED=1 S3_BUCKET=my-backups S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_REGION=us-east-1 # or S3_ENDPOINT for R2/MinIO S3_BACKUP_INTERVAL=21600000 # snapshot cadence, default 6 hours S3_BACKUP_RETENTION=7 # snapshots kept, oldest pruned ``` This requires a persistent data path. Scheduled server backups flush the pending write buffer (and fail the cycle if storage backoff leaves anything pending), then use SQLite `VACUUM INTO`, so committed WAL data is captured even when a long-lived reader prevents checkpoint truncation. ```bash bunqueue backup now # snapshot immediately bunqueue backup list # list snapshots with size and age bunqueue backup restore --force # stop the server first bunqueue backup status ``` Restore validates before it replaces: it verifies sizes and checksum, runs an integrity check on a temp copy, quarantines stale WAL/SHM sidecars, and only then swaps it in. A corrupt backup never touches your live database. Stop the server before restoring, size the interval to how much queue state you can afford to lose, and rehearse one restore before you need it. Provider setup lives in the [S3 Backup guide](/guide/backup/).
monitoring

Three endpoints, no agent.

Everything is on the HTTP port (default 6790). Point Prometheus at it or just curl it.

| Endpoint | What it gives you | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /health` | `healthy` (200) or `degraded` (503), uptime, version, job counts, memory; only degraded responses include the `storage` object (with `diskFull` on SQLite) | | `GET /healthz`, `/live` | Bare liveness probes; remain 200 while the process responds | | `GET /ready` | Readiness; returns 503 when persistent storage is degraded | | `GET /prometheus` | Metrics in Prometheus text format: job counts, totals, latency, bounded per-queue gauges, process/connections, storage and backup freshness | | `GET /metrics` | The same counters as JSON | Set `METRICS_AUTH=true` to require an `AUTH_TOKENS` bearer token on `/prometheus`; a missing token configuration fails closed with 503. Alert on these production symptoms: - **DLQ growth**: `bunqueue_jobs_dlq` climbing means handlers are failing terminally. - **Queue depth**: `bunqueue_jobs_waiting + bunqueue_jobs_prioritized` growing steadily means workers cannot keep up. - **`/health` reporting `degraded`**: inspect persistent-storage health. SQLite commonly reports a full disk; PostgreSQL may report connection, lifecycle, event-stream, projection-refresh, heartbeat, recovery, retention, or cron failures. `/ready` returns 503 until the affected subsystem recovers. - **Backup freshness**: an enabled scheduler that is stopped, failing, or has no success within two intervals puts recovery objectives at risk. - **Telemetry truncation**: `bunqueue_queue_metrics_omitted > 0` means per-queue drill-down is incomplete; review cardinality before raising the cap. - **Memory drift**: RSS should plateau once the workload does; internal caches are hard-capped. Dashboards and webhook alerting are covered in [Monitoring](/guide/monitoring/).
postgresql failover

Know the recovery clock.

PostgreSQL uses the database clock for leases and broker ownership, so host clock skew cannot create two valid owners.

`BUNQUEUE_POSTGRES_LEASE_DURATION_MS` defaults to 30 seconds. It controls the background coordination cadence as follows: | Operation | Formula | Default | | ---------------------------------- | ---------------------------------------------- | ------: | | Broker heartbeat | `max(1s, floor(leaseDurationMs / 3))` | 10 s | | Duplicate broker-ID stale takeover | `max(leaseDurationMs, 3 × heartbeat interval)` | 30 s | | Expired processing-lease scan | `max(500ms, floor(leaseDurationMs / 2))` | 15 s | The processing lease itself is still determined by the worker's `lockDuration` and renewed by heartbeats. After that lease expires, a surviving broker discovers it on the next recovery scan, so the normal recovery bound is the remaining job lease plus up to one scan interval, database/query scheduling not included. Do not lower these values merely to make failover look faster: measure database latency and pause behavior first, and alert when heartbeat or recovery health becomes degraded. See [Storage backends](/guide/databases/#broker-failover-timing).
postgresql upgrades

Treat the schema as a one-way boundary.

Schema initialization is automatic, but mixed bunqueue versions are not a supported steady state.

For the safest PostgreSQL upgrade: 1. Verify database backups/PITR and rehearse the target version against a restored clone. 2. Drain or stop every old broker before allowing the first new broker to initialize the schema. 3. Start one new broker, wait for `/ready`, and verify the schema and authoritative queue counts. 4. Start the remaining brokers at the same bunqueue version, then update clients. Do not assume an application-only rollback is safe after a schema migration. A binary that supports an older schema version refuses to start against a newer recorded version. If the new broker has migrated the database, roll forward or restore the pre-upgrade database/PITR point together with the old binaries. Zero-downtime mixed-version rollout requires explicit compatibility evidence for the exact source and target versions; it is not implied by the additive shape of the current migration.
graceful shutdown

SIGTERM is a contract.

Deploys restart the server constantly. The shutdown path is what makes that boring.

On SIGTERM the server stops accepting connections and waits up to `SHUTDOWN_TIMEOUT_MS` (default 30,000 ms) for active jobs to finish. SQLite then flushes its pending write buffer and closes the database, which is why ordinary buffered jobs survive a graceful deploy. PostgreSQL instead closes operation admission, drains work already admitted to the manager, settles deferred writes and projection repairs, releases the broker's remaining owned leases, and then closes its SQL pool. Jobs still running when the deadline hits remain in the selected persistent backend as active and are recovered for retry by SQLite startup or PostgreSQL lease recovery. Size `SHUTDOWN_TIMEOUT_MS` a little above your longest handler, and give your orchestrator a termination grace period slightly above that (Kubernetes defaults to 30 s).
go-live checklist

Before you point traffic at it.

  • SQLite only: durable: true on every job you cannot re-derive; PostgreSQL writes are transactional without this option
  • idempotency keys in every handler side effect
  • custom jobId wherever producers retry
  • SQLite: S3_BACKUP_ENABLED=1; PostgreSQL: provider backup/PITR; rehearse one restore
  • alerts on DLQ growth and waiting-queue depth
  • /healthz and /ready wired into your orchestrator
  • AUTH_TOKENS set; METRICS_AUTH if metrics are exposed
  • native TLS or a private network between clients and server
  • SHUTDOWN_TIMEOUT_MS sized above your longest handler
  • SQLite disk alerts for .db/.db-wal, or PostgreSQL storage/connection alerts
  • worker lockDuration above your slowest job, or heartbeats on

Ready to deploy?

The deployment guide has the Docker, systemd, and PM2 configs to paste.

--- # Deploy bunqueue: SQLite or PostgreSQL 15–18 Deploy bunqueue with embedded or single-broker SQLite, or a PostgreSQL 15–18 multi-broker topology, including Kubernetes, Docker, health, and backups. URL: https://bunqueue.dev/guide/deployment/ import { Aside } from '@astrojs/starlight/components';
guide · deployment

From laptop to production.

This page shows the smallest working way to run bunqueue in production, then ready-to-paste configs for Kubernetes, Docker, systemd, and PM2, plus health checks and backups.

bunqueue uses one process and memory/SQLite by default. Server mode may instead use PostgreSQL 15–18 as the authoritative store for multiple active broker processes. PostgreSQL 18.6 is the recommended release and is pinned by the repository Compose topology. Start by deciding whether the queue is embedded, a single SQLite server, or a PostgreSQL-backed broker fleet. ## The smallest deploy: embedded mode Embedded mode means the queue runs inside your application process, no separate queue server at all. Point it at a file path so jobs survive restarts: ```typescript // app.ts import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('emails', { embedded: true, dataPath: './data/bunq.db', // jobs persist here }); await queue.add('send', { to: 'user@example.com' }); new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: true, dataPath: './data/bunq.db', concurrency: 5 } ); ``` Deploy your app the way you already deploy it. The queue ships with it. Done. ## Separate apps and workers? Run the server The moment your API and your workers are separate processes (or separate containers), run the bunqueue server. One server owns the SQLite file; every other process talks to it over TCP: ```bash bunqueue start --data-path ./data/bunq.db ``` ```typescript // api.ts, pushes jobs (no embedded flag = TCP client to localhost:6789) import { Queue } from 'bunqueue/client'; const queue = new Queue('tasks'); await queue.add('process', { data: '...' }); ``` ```typescript // worker.ts, a separate process, restart and scale it independently import { Worker } from 'bunqueue/client'; new Worker( 'tasks', async (job) => { return { done: true }; }, { concurrency: 10 } ); ``` You can also push jobs without any SDK, via CLI or HTTP: ```bash bunqueue push emails '{"to": "user@example.com"}' curl -X POST http://localhost:6790/queues/emails/jobs \ -H "Content-Type: application/json" \ -d '{"data": {"to": "user@example.com"}}' ``` ## Multiple active brokers: PostgreSQL 15–18 Use the repository's pinned Compose topology when one broker is not enough: ```bash POSTGRES_PASSWORD='replace-me' \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:replace-me@postgres:5432/bunqueue' \ docker compose -f docker-compose.postgres.yml up --build -d ``` This starts `postgres:18.6-alpine`, `broker-a` on TCP/HTTP `6789`/`6790`, and `broker-b` on `7789`/`7790`. Both brokers share one URL and namespace and have different IDs. CI runs the complete PostgreSQL integration suite against majors 15, 16, 17, and the pinned 18.6 release; use 18.6 for new deployments unless a provider constraint requires another tested major. Configure the equivalent deployment with: ```bash BUNQUEUE_STORAGE_DRIVER=postgres BUNQUEUE_POSTGRES_URL='postgres://bunqueue:percent-encoded-password@postgres:5432/bunqueue' BUNQUEUE_POSTGRES_NAMESPACE=production BUNQUEUE_BROKER_ID=broker-a # unique for every active process ``` Compose passes `BUNQUEUE_POSTGRES_URL` directly instead of placing the raw `POSTGRES_PASSWORD` inside a URI. Set both values when overriding the default; percent-encode reserved characters only in the URL password component. PostgreSQL mode is standalone-server only. Do not set `BUNQUEUE_DATA_PATH`, and do not enable bunqueue's SQLite S3 snapshot feature. Use PostgreSQL-native backups/PITR and put a TCP load balancer or service in front of the brokers. See [Storage backends](/guide/databases/) for the lease and consistency model. ## Kubernetes: four brokers and one PostgreSQL service Use a managed PostgreSQL service or a PostgreSQL operator with tested backup, PITR, failover, and monitoring for production. The manifest below deliberately contains only the bunqueue fleet: inject the connection URL for your database through a Secret and replace the example image with an immutable tag or digest from your registry. ```yaml apiVersion: v1 kind: Secret metadata: name: bunqueue type: Opaque stringData: # Prefer an external secret manager or Sealed Secret in a real cluster. postgres-url: postgres://bunqueue:percent-encoded-password@postgres:5432/bunqueue auth-tokens: replace-with-a-long-random-token --- apiVersion: v1 kind: Service metadata: name: bunqueue spec: selector: app.kubernetes.io/name: bunqueue ports: - name: tcp port: 6789 targetPort: tcp - name: http port: 6790 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: bunqueue spec: replicas: 4 # bunqueue schema upgrades do not support mixed binary versions. This avoids # overlap during a version change, at the cost of a coordinated outage. strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: bunqueue template: metadata: labels: app.kubernetes.io/name: bunqueue spec: automountServiceAccountToken: false securityContext: seccompProfile: type: RuntimeDefault terminationGracePeriodSeconds: 45 initContainers: - name: wait-for-postgres image: postgres:18.6-alpine command: ['/bin/sh', '-ec'] args: - until pg_isready -d "$BUNQUEUE_POSTGRES_URL"; do sleep 1; done env: - name: BUNQUEUE_POSTGRES_URL valueFrom: secretKeyRef: name: bunqueue key: postgres-url securityContext: allowPrivilegeEscalation: false capabilities: drop: ['ALL'] runAsGroup: 70 runAsNonRoot: true runAsUser: 70 containers: - name: bunqueue image: your-registry.example/bunqueue:immutable-tag imagePullPolicy: IfNotPresent ports: - { name: tcp, containerPort: 6789 } - { name: http, containerPort: 6790 } env: # The repository image defaults DATA_PATH to SQLite. Clear it # explicitly so PostgreSQL is the only configured backend. - { name: DATA_PATH, value: '' } - { name: BUNQUEUE_STORAGE_DRIVER, value: postgres } - name: BUNQUEUE_POSTGRES_URL valueFrom: secretKeyRef: name: bunqueue key: postgres-url - { name: BUNQUEUE_POSTGRES_NAMESPACE, value: production } - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - { name: BUNQUEUE_BROKER_ID, value: '$(POD_NAME)' } - { name: BUNQUEUE_POSTGRES_POOL_SIZE, value: '4' } - { name: BUNQUEUE_POSTGRES_LEASE_DURATION_MS, value: '30000' } - { name: BUNQUEUE_POSTGRES_POLL_INTERVAL_MS, value: '250' } - { name: SHUTDOWN_TIMEOUT_MS, value: '30000' } - name: AUTH_TOKENS valueFrom: secretKeyRef: name: bunqueue key: auth-tokens startupProbe: httpGet: { path: /healthz, port: http } failureThreshold: 30 periodSeconds: 2 livenessProbe: httpGet: { path: /healthz, port: http } failureThreshold: 3 periodSeconds: 10 readinessProbe: httpGet: { path: /ready, port: http } failureThreshold: 3 periodSeconds: 5 resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: '1', memory: 512Mi } securityContext: allowPrivilegeEscalation: false capabilities: drop: ['ALL'] readOnlyRootFilesystem: true runAsGroup: 1001 runAsNonRoot: true runAsUser: 1001 --- apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: bunqueue spec: minAvailable: 1 selector: matchLabels: app.kubernetes.io/name: bunqueue ``` The `initContainer` is intentional. A bunqueue process fails fast when the database is unreachable during startup; an HTTP readiness probe cannot delay a dependency that must exist before the process starts. Once running, `/ready` removes a broker from Service endpoints when PostgreSQL or a maintenance loop is degraded, while `/healthz` remains a process-liveness signal. The PostgreSQL schema initialization lock makes simultaneous cold starts safe. Every Pod must have a different `BUNQUEUE_BROKER_ID`. The Downward API mapping above supplies the immutable Pod name, including after replacement. All Pods must use the same PostgreSQL URL and `BUNQUEUE_POSTGRES_NAMESPACE`. Budget at least `replicas × BUNQUEUE_POSTGRES_POOL_SIZE` database connections, plus room for migrations, administration, monitoring, replicas, and failover. The resource values are starting points, not capacity claims; benchmark the actual payload, retention, concurrency, and database latency. The ClusterIP Service balances TCP **connections**, not individual commands. A long-lived SDK connection remains on its selected broker; connection pools and independent clients distribute naturally across ready endpoints. Keep both ports private or add authenticated TLS termination. Set `terminationGracePeriodSeconds` higher than `SHUTDOWN_TIMEOUT_MS`, and add topology spread or anti-affinity appropriate to your node count. ## Docker For the prebuilt server, use Docker Hub (available from 2.9.5): ```bash docker run -d --name bunqueue \ -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ egeominotti/bunqueue:2.9.5 ``` The same release is available as `ghcr.io/egeominotti/bunqueue:2.9.5`. Both registries provide Linux amd64 and arm64 images. The volume preserves SQLite data across container replacement. Pin a version or digest for deployments. Choose `2.9.5-alpine`, `2.9.5-debian`, `2.9.5-slim`, or `2.9.5-distroless`. The moving tags are `alpine`, `debian`, `slim`, and `distroless`. Unsuffixed version tags and `latest` continue to select Alpine. All variants support amd64 and arm64, run as UID/GID `1001:1001`, and share the same ports and persistent data path. Alpine uses musl; Debian and Debian slim use glibc. Distroless uses Debian 13 and contains no shell or package manager. Each image contains the compiled server, required system libraries, and CA certificates, without project dependencies or a separate Bun installation. The exec-form health check runs `/app/bunqueue healthcheck`. It requests `http://127.0.0.1:$HTTP_PORT/health` (default port `6790`), requires a healthy JSON response, and fails after five seconds. It works with server authentication enabled. If you change the listener using a config file or CLI flags, use a custom hostname, or enable HTTPS, override the probe with the matching URL. For distroless, use a derived Dockerfile with JSON exec form: ```dockerfile FROM egeominotti/bunqueue:2.9.5-distroless HEALTHCHECK CMD ["/app/bunqueue", "healthcheck", "https://broker.example:8443/health"] ``` HTTPS probes verify certificates. The URL must match the certificate and use a trusted CA. The HTTP probe does not support Unix sockets; socket-only deployments must configure their own health check. Bind-mounted data directories must be writable by UID `1001`. To package your own application with bunqueue, build an image from your project: ```dockerfile FROM oven/bun:1.4.2-alpine WORKDIR /app COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile --production COPY . . RUN mkdir -p /app/data ENV BUNQUEUE_DATA_PATH=/app/data/bunq.db ENV NODE_ENV=production # wget ships with the Alpine base, curl does not HEALTHCHECK --interval=30s --timeout=3s \ CMD wget --spider -q http://127.0.0.1:6790/health || exit 1 EXPOSE 6789 6790 # "start" is your own package.json script (e.g. "bunqueue start") CMD ["bun", "run", "start"] ``` ```yaml # docker-compose.yml services: bunqueue: build: . ports: - '6789:6789' # TCP (SDK clients) - '6790:6790' # HTTP (health, metrics, REST) volumes: - bunqueue-data:/app/data environment: - BUNQUEUE_DATA_PATH=/app/data/bunq.db - AUTH_TOKENS=${AUTH_TOKENS} - S3_BACKUP_ENABLED=1 - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_BUCKET=${S3_BUCKET} - S3_REGION=${S3_REGION} restart: unless-stopped deploy: resources: limits: memory: 512M volumes: bunqueue-data: ``` ## systemd For bare-metal or VM deployments: ```ini # /etc/systemd/system/bunqueue.service [Unit] Description=bunqueue Job Queue After=network.target [Service] Type=simple User=bunqueue Group=bunqueue WorkingDirectory=/var/lib/bunqueue ExecStart=/usr/local/bin/bunqueue start Restart=always RestartSec=5 Environment=NODE_ENV=production Environment=BUNQUEUE_DATA_PATH=/var/lib/bunqueue/bunq.db EnvironmentFile=/etc/bunqueue.env NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/bunqueue MemoryMax=512M [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl daemon-reload sudo systemctl enable --now bunqueue sudo journalctl -u bunqueue -f ``` To upgrade: stop the service, `bun install -g bunqueue@latest`, start it again, then confirm with `curl http://localhost:6790/health | jq .version`. Updating the package does not restart the service. ## PM2 Build the standalone binary first (`bun run build` in the repo produces `dist/bunqueue`, a self-contained executable that bundles the Bun runtime), or install globally. ```javascript // ecosystem.config.js module.exports = { apps: [ { name: 'bunqueue', script: '/usr/local/bin/bunqueue', args: 'start', instances: 1, // required for SQLite; PostgreSQL brokers use unique IDs exec_mode: 'fork', autorestart: true, max_memory_restart: '512M', env: { NODE_ENV: 'production', DATA_PATH: '/var/lib/bunqueue/bunq.db', TCP_PORT: 6789, HTTP_PORT: 6790, }, }, ], }; ``` ```bash pm2 start ecosystem.config.js pm2 save && pm2 startup # survive reboots ``` ## Health checks The HTTP port (default 6790) serves everything an orchestrator needs: ```bash curl http://localhost:6790/health # detailed: status, version, job counts, memory curl http://localhost:6790/healthz # bare liveness, returns OK curl http://localhost:6790/ready # readiness curl http://localhost:6790/prometheus # metrics in Prometheus text format ``` `/health` and `/ready` return 503 when persistent storage is degraded (including SQLite disk-full and PostgreSQL runtime errors); `/healthz` remains a pure process-liveness check. PostgreSQL SQLSTATE, constraint, host, driver, and network details stay in local diagnostics: client-facing health payloads use `Internal server error`, while SQLite disk-full retains its actionable message. If `METRICS_AUTH=true`, also configure `AUTH_TOKENS` and the scraper bearer token; otherwise `/prometheus` fails closed with 503. ```yaml # Kubernetes livenessProbe: httpGet: { path: /healthz, port: 6790 } initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: { path: /ready, port: 6790 } initialDelaySeconds: 5 periodSeconds: 5 ``` Alerting thresholds and dashboards are covered in [Monitoring](/guide/monitoring/) and [Production Operations](/guide/production/). ## SQLite backups and restore The whole queue is one SQLite file, so back up that one file. The built-in S3 backup uploads periodic snapshots (works with AWS S3, Cloudflare R2, MinIO): ```bash S3_BACKUP_ENABLED=1 S3_BUCKET=my-bunqueue-backups S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_REGION=us-east-1 # or S3_ENDPOINT for R2/MinIO S3_BACKUP_INTERVAL=3600000 # every hour (default 6h) S3_BACKUP_RETENTION=24 # snapshots kept ``` The persistent data path configured earlier is mandatory. The server flushes its pending write buffer, then asks SQLite for a `VACUUM INTO` snapshot that includes committed WAL frames; do not replace this with a raw live-file copy. To restore after a disk loss: ```bash systemctl stop bunqueue bunqueue backup list bunqueue backup restore backups/bunq-2026-01-30T12:00:00.db --force systemctl start bunqueue ``` Provider-specific setup (R2, MinIO, Spaces) and how restore verifies SHA-256, checks SQLite integrity, and quarantines stale sidecars before replacing anything: [S3 Backup guide](/guide/backup/). For PostgreSQL, use the database provider's backup, replication, and point-in-time-recovery facilities. `S3_BACKUP_ENABLED=1` with the PostgreSQL driver is rejected at startup. ## Reference: key environment variables | Variable | What it does | Default | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------- | | `BUNQUEUE_STORAGE_DRIVER` | `memory`, `sqlite`, or `postgres` | inferred | | `BUNQUEUE_DATA_PATH` | SQLite file path (aliases, in priority order: `BQ_DATA_PATH`, `DATA_PATH`, `SQLITE_PATH`) | in-memory | | `BUNQUEUE_POSTGRES_URL` | PostgreSQL server connection URL | none | | `BUNQUEUE_POSTGRES_NAMESPACE` | Shared installation namespace | `default` | | `BUNQUEUE_BROKER_ID` | Unique PostgreSQL broker identity | generated | | `BUNQUEUE_POSTGRES_POOL_SIZE` | SQL connections per broker; budget `brokers × poolSize` plus operational headroom | `4` | | `BUNQUEUE_POSTGRES_LEASE_DURATION_MS` | Broker coordination and recovery cadence input | `30000` | | `BUNQUEUE_POSTGRES_POLL_INTERVAL_MS` | Durable-event and cron fallback polling interval | `250` | | `BUNQUEUE_POSTGRES_STATEMENT_TIMEOUT_MS` | Maximum SQL statement duration | `30000` | | `BUNQUEUE_POSTGRES_LOCK_TIMEOUT_MS` | Maximum wait for a PostgreSQL lock | `5000` | | `BUNQUEUE_POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS` | Maximum idle time inside a transaction | `30000` | | `BUNQUEUE_POSTGRES_MAX_CONCURRENT_OPERATIONS` | Active PostgreSQL manager operations per broker | `16` | | `BUNQUEUE_POSTGRES_MAX_QUEUED_OPERATIONS` | Waiting operations before fail-fast saturation | `128` | | `TCP_PORT` | Port for SDK clients | `6789` | | `HTTP_PORT` | Port for health, metrics, REST | `6790` | | `AUTH_TOKENS` | Comma-separated tokens clients must present | none (open) | | `TLS_CERT_FILE` / `TLS_KEY_FILE` | PEM cert and key for native TLS on TCP and HTTP. Set both or neither; setting only one is a startup error | off | | `S3_BACKUP_ENABLED` | Turn on S3 snapshots | `0` | | `SHUTDOWN_TIMEOUT_MS` | How long a graceful shutdown waits for active jobs | `30000` | The full list, including all S3 and cloud variables, lives in [Environment Variables](/guide/env-vars/). Server CLI flags include `bunqueue start --tcp-port --http-port --data-path --max-completed-jobs --completed-retention-ms --auth-tokens --tls-cert --tls-key`. ## Sizing by backend There is no backend-independent CPU/RAM table: retained payload size, command batching, worker concurrency, journal retention, database latency, and observability history all change the resource curve. Measure admission, processing, and complete lifecycle separately with production-shaped payloads. For memory/SQLite, size the bunqueue process for its in-memory indexes and retained jobs, and place the database on durable low-latency storage. Set `removeOnComplete: true` when completed rows do not need to remain queryable, and keep enough failed jobs for diagnosis. One SQLite file still has exactly one broker owner; adding broker processes is not a scaling mechanism. For PostgreSQL, budget two layers independently: - **Bun brokers:** memory for bounded compatibility snapshots, active workers, connections, and command batches. Start with the default pool of four per broker and the default 16 active/128 queued operation admission limits. - **Database:** `brokers × poolSize` application connections plus administration, monitoring, replication, and failover headroom. Size CPU, `shared_buffers`, WAL, storage IOPS, autovacuum, and replica replay capacity from measured churn. The native engineering campaign found two brokers faster than one for its fixed 16-consumer workload, while four brokers added availability but not linear throughput. Treat that as a contention warning, not a universal broker-count recommendation. See [PostgreSQL benchmark evidence](/guide/benchmarks/#postgresql-1518-multi-broker). ## Gotchas - **SQLite is single-broker.** Horizontal broker scaling requires PostgreSQL 15–18 mode; 18.6 is recommended. PostgreSQL/database HA, routing, and backup remain operational responsibilities; bunqueue is not a multi-region consensus system. - **Two embedded processes, one file, is silent corruption.** See the warning above; always go through the server for multi-process. - **Copying the database while the server runs can produce a torn copy.** SQLite runs in WAL mode (writes go to a `.db-wal` sidecar file first). Stop the server, then copy `bunq.db` and `bunq.db-wal` together, or just use the built-in S3 backup, which snapshots safely while live. - **`AUTH_TOKENS` is not set by default.** Anyone who can reach the ports can push and pull jobs. Set tokens (`openssl rand -hex 32`) and either enable [native TLS](/guide/tls/) or keep the ports on a private network. See [Security](/security/). - **SQLite jobs that cannot tolerate bunqueue's process-crash buffer should be `durable: true`.** SQLite buffers ordinary writes for up to 10 ms; `durable` commits before the add returns. Host/filesystem/media durability remains operational. PostgreSQL mutations are already transactional and do not use this buffer. Details in [Production Operations](/guide/production/). - **Do not improvise a mixed-version PostgreSQL rollout.** Stop the old broker fleet before the first new binary migrates the schema, verify one new broker, then start the rest at the same version. An old binary may reject the newer recorded schema; use roll-forward or a coordinated database/PITR restore for rollback. See [PostgreSQL upgrades](/guide/production/#postgresql-schema-upgrades). --- # Storage: SQLite or PostgreSQL 15–18 Use memory or one-file SQLite for a zero-infrastructure bunqueue, or PostgreSQL 15–18 for a database-authoritative multi-broker server deployment. URL: https://bunqueue.dev/guide/databases/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · storage

One-file SQLite. PostgreSQL for many brokers.

Keep the zero-infrastructure SQLite engine for embedded and single-broker deployments, or opt into PostgreSQL 15–18 when several bunqueue servers must share one authoritative queue.

The storage choice is explicit and deployment-specific. SQLite behavior has not changed. PostgreSQL is a separate server backend; MySQL is not supported. ## The short answer bunqueue still needs **zero external infrastructure by default**. Without a data path it is memory-only; set one path for SQLite persistence. The only runtime dependency is `msgpackr`, and cron parsing is provided by Bun itself. If one server or embedded process and a durable disk meet your needs, use SQLite: ```bash # Server mode: point the data path at durable storage BUNQUEUE_DATA_PATH=/data/bunq.db bunqueue start ``` ```typescript // Embedded mode (queue runs inside your app's process) const queue = new Queue('jobs', { embedded: true, dataPath: '/data/jobs.db' }); ``` _Embedded mode (and `queue.forward()` below) ships in the Bun `bunqueue` package only. From every other language, run the server and connect with a [client SDK](/guide/sdks/), as in Option 3._ For multiple active broker processes, use the PostgreSQL server backend. It is database-authoritative: claims, leases, ACK/FAIL, queue limits, Pro-style group capacity/order/pause/rate state, cron schedules, worker registrations, job-state/lifecycle metrics, and events are coordinated in PostgreSQL rather than independent in-memory queues. Here, metrics means job-state and lifecycle families; process/connection and registration collectors remain broker-local. ## PostgreSQL 15–18 multi-broker mode PostgreSQL is available in **standalone server mode**. Embedded `Queue` and `Worker` continue to use memory/SQLite. CI runs the complete integration suite against majors 15, 16, 17, and PostgreSQL 18.6. Version 18.6 is recommended for new deployments and pinned by the repository Compose topology; the other three majors remain tested compatibility targets. The repository Compose file pins the requested PostgreSQL version exactly: ```bash POSTGRES_PASSWORD='replace-me' \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:replace-me@postgres:5432/bunqueue' \ docker compose -f docker-compose.postgres.yml up --build -d ``` The database password and broker URL are separate Compose inputs so a raw secret is never interpolated into a URI. When the password contains URI-reserved characters, percent-encode the password component in `BUNQUEUE_POSTGRES_URL` (for example, `p@ss/word` becomes `p%40ss%2Fword`) while passing the original value as `POSTGRES_PASSWORD`. It starts `postgres:18.6-alpine` and two bunqueue brokers against the same database and namespace: | Service | TCP | HTTP | | ---------- | -----: | -----: | | `broker-a` | `6789` | `6790` | | `broker-b` | `7789` | `7790` | To configure a broker directly: ```bash BUNQUEUE_STORAGE_DRIVER=postgres \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@postgres:5432/bunqueue' \ BUNQUEUE_POSTGRES_NAMESPACE=production \ BUNQUEUE_BROKER_ID=broker-a \ bunqueue start ``` Or use `bunqueue.config.ts`: ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ storage: { driver: 'postgres', url: process.env.BUNQUEUE_POSTGRES_URL!, namespace: 'production', brokerId: process.env.HOSTNAME, poolSize: 4, leaseDurationMs: 30_000, pollIntervalMs: 250, statementTimeoutMs: 30_000, lockTimeoutMs: 5_000, idleTransactionTimeoutMs: 30_000, maxConcurrentOperations: 16, maxQueuedOperations: 128, maxSnapshotJobs: 100_000, maxSnapshotPayloadBytes: 256 * 1024 * 1024, }, }); ``` Every active broker needs a unique `brokerId` and the same URL/namespace. A live duplicate fails startup. Each process also owns an internal random session fence, so a stale process cannot release leases or workers belonging to its successor. Credentials should come from a secret manager. Do not also set a SQLite data path: ambiguous PostgreSQL + SQLite configuration fails startup. The built-in S3 snapshot feature is SQLite-only; use normal PostgreSQL backup/PITR tooling for this backend. The broker count is not limited to the two services in the example. The PostgreSQL integration gate also starts four independent bunqueue processes, each with its own ports and connection pool, against one PostgreSQL 18.6 namespace. It exercises concurrent production/consumption, shared rate and concurrency limits, group max-size admission, priority/FIFO rotation, group pause/manual deadlines, queue pause/resume, cross-broker ACK, and recovery after one broker is killed while holding leases. PostgreSQL transactions use `FOR UPDATE SKIP LOCKED` for competing claims and opaque, database-clock leases for fencing. LISTEN/NOTIFY only wakes other brokers; a transactional outbox ordered at commit time repairs missed or coalesced notifications even when physical event IDs were allocated in a different order. An expired owner cannot ACK a generation after another broker recovers it. Dependency admission queries PostgreSQL and rechecks existence in the write transaction, so broker B can reference a parent just committed on broker A without relying on event timing. Worker and cron views in dashboards, per-queue worker routes, and streamed stats likewise read the shared registry. PostgreSQL-specific bulk paths keep database work proportional to the command: one worker heartbeat batch uses one fenced transaction and one set-based update, with a pre-write generation ticket preventing late responses from replacing a newer terminal or re-leased local projection, dependency-free `PUSHB` validation does not materialize the local compatibility snapshot, and dashboard queue counts aggregate that snapshot once. Event retention uses exact transaction-private deltas and a consolidated per-queue count, so under-cap writes do not scan the retained journal window or lock shared counter rows. These optimizations do not change the memory or SQLite engines. Completion proofs used by live dependencies are pinned in PostgreSQL; unused `removeOnComplete` proofs and each broker's completed/result snapshot are bounded independently. Reusing a custom ID retires the previous proof only when no live consumer still owns it. Destructive commands use the same identity lock as admission, so drain, clean, TTL/DLQ pruning, and obliterate cannot strand a `waiting-children` job. PostgreSQL also requires at least one retained durable event per queue; its runtime rejects `maxQueueEvents: 0`. SQLite and in-memory retention behavior is unchanged. ### Broker failover timing PostgreSQL uses the database clock for processing leases, broker heartbeats, and stale-session takeover. `leaseDurationMs` defaults to 30,000 ms and drives the background coordination cadence: | Operation | Formula | Default | | ---------------------------------- | ---------------------------------------------- | ------: | | Broker heartbeat | `max(1000, floor(leaseDurationMs / 3))` | 10 s | | Duplicate broker-ID stale takeover | `max(leaseDurationMs, 3 × heartbeat interval)` | 30 s | | Expired processing-lease scan | `max(500, floor(leaseDurationMs / 2))` | 15 s | A worker's `lockDuration` still determines when its processing generation expires. A surviving broker normally recovers that generation on the next recovery scan, so detection can take the remaining processing lease plus up to one scan interval, before database and scheduler latency. A replacement process reusing the same `brokerId` must wait for the stale-takeover window; a different unique broker ID can start immediately. Lowering the coordination duration also increases heartbeat and recovery traffic, so tune it from measured database latency and pause behavior rather than desired failover time alone. ### PostgreSQL 18 performance settings bunqueue does not override server-wide PostgreSQL settings. PostgreSQL 18 uses the `worker` asynchronous I/O method by default, but this queue workload is normally dominated by cached B-tree lookups, WAL, and row coordination rather than cold sequential reads. Measure the actual server before increasing `io_workers` or `io_max_concurrency`; more workers can add overhead when the working set is already cached. For a dedicated database, size `shared_buffers` from the available memory and working set (PostgreSQL documents roughly 25% of system memory as a starting point, not a universal target). `wal_compression=lz4` can reduce full-page-image WAL at a CPU cost. Keep `fsync=on`, `synchronous_commit=on`, and `full_page_writes=on` when acknowledged queue writes must survive a database or host crash. Disabling those durability controls is not a bunqueue performance mode. Profile with `pg_stat_statements`, `track_io_timing`, `pg_stat_io`, and `pg_stat_wal`. In the native 20,000-job/16-consumer engineering profile, the final indexed claim path produced zero temp files and read almost entirely from shared buffers. Its final rates were 11,749 admission, 9,782 processing, and 5,338 complete lifecycle jobs/s with two managers and 16 claim loops. A 21-instance PostgreSQL 18.6 matrix found only a 6.3% median uplift from a 512 MiB shared buffer allocation and no decisive AIO/JIT/WAL compression winner. Treat those figures as local diagnostics and benchmark the production storage, payload size, broker count, and journal retention window. After the commit-ordered journal was finalized, a fresh 10,000-job profile with the same two-manager/16-loop shape measured 11,318 admission, 10,064 processing, and 5,327 lifecycle jobs/s. Moving the commit token to a compact envelope removed the second event-row rewrite and its roughly 49 MiB of profiled WAL per 10,000-job run. These remain local engineering diagnostics, not production sizing claims. ### Connection security and deadlines Use a PostgreSQL URL with the SSL mode required by your provider, for example `?sslmode=verify-full`; install the provider CA in the host trust store and do not downgrade certificate verification in production. Bun passes PostgreSQL runtime parameters when each pooled connection opens: bunqueue defaults to a 30-second statement timeout, 5-second lock timeout, and 30-second idle transaction timeout. Set stricter values only after measuring the longest valid queue maintenance operation. Schema migration uses the same lock deadline, so a busy rollout fails safely instead of waiting indefinitely and can be retried. The Bun SQL pool also uses a 10-second connection timeout, closes connections after 30 seconds idle, and rotates connections after a maximum lifetime of 3,600 seconds. These lifecycle values are fixed runtime safeguards rather than public configuration fields. Include the connection timeout in failover expectations and ensure the provider, proxy, and DNS behavior can reconnect within the application's retry policy. The default pool is four connections per broker. Budget total database connections as `brokers × poolSize`, plus administration, monitoring, and failover headroom. bunqueue also admits 16 active and 128 queued PostgreSQL operations per broker by default. Once that bounded queue is full, commands fail fast and callers may retry with jitter instead of consuming unbounded process memory during an outage. ### Schema upgrades, mixed versions, and rollback Schema initialization is automatic and protected by a PostgreSQL advisory lock, but mixed bunqueue binary versions are not a supported steady state. The safe upgrade procedure is: 1. Verify backup/PITR and test the target bunqueue version against a restored clone. 2. Drain or stop all old brokers before the first new binary initializes the database. 3. Start one new broker and wait for `/ready`; verify schema health and authoritative counts. 4. Start the remaining brokers at the same bunqueue version, then update clients. The initializer refuses a database whose recorded schema version is newer than the binary supports. Consequently, after a migration, restarting an old binary may fail and an application-only downgrade is not a rollback plan. Roll forward, or restore the pre-upgrade database/PITR point together with the old binaries. PostgreSQL schema v21 adds exact event-retention state, transaction-private deltas, and guarded statement-level insert/delete triggers; it therefore requires the same all-brokers-together upgrade procedure. If those derived retention tables drift, initialization locks event writes, repairs their exact primary keys, rebuilds counts from the journal, and restores the triggers in one transaction. The current migrations are additive, but that does not by itself certify every pair of releases for zero-downtime mixed-version operation; validate the exact source/target pair on a clone if continuous availability is mandatory. ### Vacuum, failover, and recovery drills Queue churn creates dead tuples in jobs, events, commit envelopes, completions, metrics, and logs. Keep autovacuum enabled, monitor `n_dead_tup`, vacuum lag, table/index growth, transaction age, WAL generation, and replica replay lag, and tune per-table autovacuum thresholds from observed churn. Adaptive journal GC does not replace vacuum. Use PostgreSQL physical or managed-service backups with PITR. Test restores and primary promotion regularly: restore to a fresh cluster, start one broker, verify schema/health and authoritative counts, then add the remaining brokers. DNS or proxy failover must preserve the same database and namespace. A database restored to an earlier point can legitimately replay jobs whose later ACK was not part of that recovery point, so processors must remain idempotent and use application-level deduplication for external effects. ## SQLite retention and schema upgrades `maxCompletedJobs` is a hot-cache/recovery bound, not a database-retention policy. `queue.clean(..., 'completed')` is SQLite-authoritative and deletes the oldest eligible retained rows even after they leave that cache. For automatic retention, set `storage.completedRetentionMs`, `BUNQUEUE_COMPLETED_RETENTION_MS`, or `--completed-retention-ms`; it is disabled by default and removes at most 1,000 rows per cleanup tick. Live dependency consumers protect the completed rows and results they still require. Completed-only queues remain visible in queue listings while any durable row exists, including after restart; the cleanup tick unregisters the name after the last row is committed away. Invalid direct retention values cannot make that deletion immediate: negative, non-finite, and unsafe values disable the policy, while finite non-negative fractions are floored to milliseconds. Deleting retained rows makes their SQLite pages reusable, which bounds future growth for a steady workload, but it does not automatically shrink a database file that is already large. To return that existing free space to the filesystem, stop bunqueue and run SQLite `VACUUM` in a maintenance window. The operation rewrites the database, so provision enough temporary free disk space and take a backup first. SQLite schema upgrades run before TCP and HTTP listeners bind. Startup logs the source/target schema versions, database size, each migration step, periodic row/byte progress for legacy payload rewrites, completion duration, and a structured failure if a step cannot finish. The legacy name backfills commit at most 500 rows or 8 MiB of source payload per transaction (one larger row is processed alone) and checkpoint the cursor in the same commit. After interruption, restart with the same or a newer bunqueue version to continue from that checkpoint. Do not downgrade an in-place, partially or fully migrated database: once a payload-rewrite batch commits, an older binary may no longer understand those rows. Roll forward, or restore the pre-upgrade backup together with the old binary. A database whose recorded schema is newer than the running binary is rejected before startup configuration can mutate it. Because listeners bind only after migration and recovery, this process does not serve HTTP 503 during the upgrade; use process logs and your supervisor's readiness state until the server starts listening. The rest of this page covers hosts where the disk does not survive restarts. ## Deploying without a durable local disk ### Option 1: Mount a persistent volume (simplest) Most container platforms can attach a durable disk. Point the data path at it and SQLite behaves normally across restarts. | Platform | Durable storage | | ---------------- | -------------------------------------------- | | Fly.io | Fly Volumes | | Railway | Volumes | | Render | Persistent Disks | | Docker / Compose | A named volume mounted at the data directory | | Kubernetes | A PersistentVolumeClaim | :::note bunqueue runs SQLite in WAL mode (write-ahead logging, a mode that allows concurrent reads and writes). WAL creates `-wal` and `-shm` sidecar files next to the `.db` file, so mount the whole data directory on the durable volume, not just the database file. ::: ### Option 2: Store-and-forward with a persistent local spool When the uplink can fail but the instance has a persistent local volume, run bunqueue embedded and forward jobs to one central, durable bunqueue server: ```typescript const local = new Queue('ingest', { embedded: true, dataPath: '/var/lib/bunqueue/spool.db', defaultJobOptions: { durable: true }, // close SQLite's 10ms hard-crash window }); const forwarder = local.forward({ to: { host: 'central.internal', port: 6789, tls: true }, queue: 'ingest', // optional remote queue name }); ``` If the central server is unreachable, jobs stay local and retry; permanent failures land in the local DLQ (dead letter queue, the holding area for jobs that exhausted their retries). This protects network outages while the local process and volume survive. A `/tmp` spool on a scale-to-zero instance is not durable: if no volume can be attached, write directly to the central server instead. Full walkthrough: [IoT & Edge](/guide/iot-edge/). ### Option 3: One central server, stateless workers Run a single bunqueue server on a host with a durable disk. Producers and workers connect over TCP and hold no state themselves: ```typescript const queue = new Queue('jobs', { connection: { host: 'queue.internal', port: 6789 } }); const worker = new Worker('jobs', processor, { connection: { host: 'queue.internal', port: 6789 }, }); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; const queue = new Queue('jobs', { host: 'queue.internal', port: 6789 }); const worker = new Worker('jobs', processor, { host: 'queue.internal', port: 6789 }); ``` ```python from bunqueue import Queue, Worker queue = Queue("jobs", host="queue.internal", port=6789) worker = Worker("jobs", process, host="queue.internal", port=6789) worker.run() ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('jobs', ['host' => 'queue.internal', 'port' => 6789]); $worker = new Worker('jobs', $processor, ['host' => 'queue.internal', 'port' => 6789]); $worker->run(); ``` ```go queue := bunqueue.NewQueue("jobs", bunqueue.Options{Host: "queue.internal", Port: 6789}) worker := bunqueue.NewWorker("jobs", processor, bunqueue.WorkerOptions{ Host: "queue.internal", Port: 6789, }) worker.Run() ``` ```rust use bunqueue_client::{ConnectionOptions, Queue, Worker, WorkerOptions}; let connection = ConnectionOptions { host: "queue.internal".into(), port: 6789, ..Default::default() }; let queue = Queue::new("jobs", connection.clone()); let worker = Worker::new("jobs", processor, WorkerOptions { connection, ..Default::default() }); worker.run()?; ``` ```elixir queue = Bunqueue.queue("jobs", host: "queue.internal", port: 6789) worker = Bunqueue.Worker.new("jobs", processor, host: "queue.internal", port: 6789) Bunqueue.Worker.run(worker) ``` Clients exist for Node.js, Deno, Python, PHP, Go, Rust, Elixir, and Cloudflare Workers, see [SDKs](/guide/sdks/). ### Choosing | Your situation | Use | | -------------------------------------------------- | -------------------------------- | | Container with an attachable disk | Persistent volume (Option 1) | | Intermittent uplink plus a persistent local volume | Store-and-forward (Option 2) | | Scale-to-zero instance with no durable local disk | Direct central server (Option 3) | | Many stateless workers, one durable host | Central TCP server (Option 3) | | Multiple active bunqueue brokers sharing state | PostgreSQL 15–18 backend | ## Guarantees and boundaries | Capability | Memory / SQLite | PostgreSQL 15–18 | | -------------------------------- | ------------------------------------------- | ---------------------------- | | Embedded mode | Yes | No | | Standalone server | Yes | Yes | | Active brokers sharing one queue | One | Multiple | | Authority | Process memory + optional SQLite durability | PostgreSQL transactions | | Claim coordination | In-process locks | Row locks + `SKIP LOCKED` | | Lease clock | Broker process | PostgreSQL | | Job-group ordering and capacity | In-process; SQLite persists config/order | Transactional across brokers | | Group pause/manual deadline | Pause persists; live deadline is in memory | Both persist in PostgreSQL | | Built-in S3 snapshots | SQLite only | No; use database backups | | MySQL compatibility | No | No | The existing SQLite performance figures apply only to SQLite. PostgreSQL functional validation is not a benchmark, and its throughput depends on network, database sizing, connection pool, retention, and transaction latency. ## See also - [Deployment Guide](/guide/deployment/) - Docker, systemd, and PM2 - [IoT & Edge](/guide/iot-edge/) - Store-and-forward in depth - [Environment Variables](/guide/env-vars/) - SQLite and PostgreSQL settings - [Comparison](/guide/comparison/) - bunqueue vs Redis-backed queues - [FAQ](/faq/) --- # Native TLS Encryption for bunqueue TCP & HTTP Encrypt bunqueue TCP and HTTP traffic with native TLS, no reverse proxy needed. Server cert/key setup, client options, CLI flags, self-signed certs. URL: https://bunqueue.dev/guide/tls/ import { Tabs, TabItem } from '@astrojs/starlight/components';
server · tls

TLS without the reverse proxy.

Point bunqueue at a certificate and key, and all traffic between clients and the server is encrypted. No nginx or Caddy in front, one cert pair covers both the TCP and HTTP ports.

1 cert pair covers TCP and HTTP 0 reverse proxies needed fail-fast startup on partial config
TLS (the encryption behind `https://`) is opt-in: without a cert and key the server runs in plaintext, exactly as before. Turn it on whenever clients connect over a network you don't fully trust. ## Enable TLS on the server ```bash # CLI flags bunqueue start --tls-cert ./cert.pem --tls-key ./key.pem # Or environment variables TLS_CERT_FILE=./cert.pem TLS_KEY_FILE=./key.pem bunqueue start ``` Or in `bunqueue.config.ts`: ```typescript import { defineConfig } from 'bunqueue'; export default defineConfig({ server: { tlsCertFile: './cert.pem', tlsKeyFile: './key.pem', }, }); ``` One cert pair covers both servers: TCP (`:6789`) and HTTP/WebSocket/SSE (`:6790`, which becomes `https://` / `wss://`). The server fails fast at startup if the cert or key file is missing, or if only one of the two is set. It never silently falls back to plaintext. ## Connect a client ```typescript import { Queue, Worker } from 'bunqueue/client'; // Public CA (Let's Encrypt etc.): verify with system CAs const queue = new Queue('jobs', { connection: { host: 'queue.example.com', port: 6789, tls: true }, }); // Private CA or self-signed: trust a specific CA file const queue2 = new Queue('jobs', { connection: { host: '10.0.0.5', port: 6789, tls: { caFile: './ca.pem' } }, }); // Dev only: skip verification const queue3 = new Queue('jobs', { connection: { host: 'localhost', port: 6789, tls: { rejectUnauthorized: false } }, }); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; // Public CA (Let's Encrypt etc.): verify with system CAs const queue = new Queue('jobs', { host: 'queue.example.com', port: 6789, tls: true }); // Private CA or self-signed: trust a specific CA file const queue2 = new Queue('jobs', { host: '10.0.0.5', port: 6789, tls: { caFile: './ca.pem' } }); // Dev only: skip verification const queue3 = new Queue('jobs', { host: 'localhost', port: 6789, tls: { rejectUnauthorized: false } }); ``` ```python from bunqueue import Queue # Public CA (Let's Encrypt etc.): verify with system CAs queue = Queue("jobs", host="queue.example.com", port=6789, tls=True) # Private CA or self-signed: trust a specific CA file queue2 = Queue("jobs", host="10.0.0.5", port=6789, tls={"ca_file": "./ca.pem"}) # Dev only: skip verification (a preconfigured ssl.SSLContext also works) queue3 = Queue("jobs", host="localhost", port=6789, tls={"verify": False}) ``` ```php use Bunqueue\Queue; // Public CA (Let's Encrypt etc.): verify with system CAs $queue = new Queue('jobs', ['host' => 'queue.example.com', 'port' => 6789, 'tls' => true]); // Private CA or self-signed: trust a specific CA file $queue2 = new Queue('jobs', ['host' => '10.0.0.5', 'port' => 6789, 'tls' => ['caFile' => './ca.pem']]); // Dev only: skip verification $queue3 = new Queue('jobs', ['host' => 'localhost', 'port' => 6789, 'tls' => ['verifyPeer' => false]]); ``` ```go // Public CA (Let's Encrypt etc.): verify with system CAs queue := bunqueue.NewQueue("jobs", bunqueue.Options{ Host: "queue.example.com", Port: 6789, TLS: &bunqueue.TLSOptions{}, }) // Private CA or self-signed: trust a specific CA file queue2 := bunqueue.NewQueue("jobs", bunqueue.Options{ Host: "10.0.0.5", Port: 6789, TLS: &bunqueue.TLSOptions{CAFile: "./ca.pem"}, }) // Dev only: skip verification queue3 := bunqueue.NewQueue("jobs", bunqueue.Options{ Host: "localhost", Port: 6789, TLS: &bunqueue.TLSOptions{InsecureSkipVerify: true}, }) ``` ```rust use std::path::PathBuf; use bunqueue_client::{ConnectionOptions, Queue, TlsOptions}; // Public CA (Let's Encrypt etc.): verify with system CAs let queue = Queue::new("jobs", ConnectionOptions { host: "queue.example.com".into(), tls: Some(TlsOptions::default()), ..Default::default() }); // Private CA or self-signed: trust a specific CA file let queue2 = Queue::new("jobs", ConnectionOptions { host: "10.0.0.5".into(), tls: Some(TlsOptions { ca_file: Some(PathBuf::from("./ca.pem")) }), ..Default::default() }); ``` *Rust deliberately exposes no insecure TLS mode, verification is always on. For a self-signed cert, trust it through `ca_file`.* ```elixir # Public CA (Let's Encrypt etc.): verify with system CAs queue = Bunqueue.queue("jobs", host: "queue.example.com", port: 6789, tls: true) # Private CA or self-signed: trust a specific CA file queue2 = Bunqueue.queue("jobs", host: "10.0.0.5", port: 6789, tls: true, ca_file: "./ca.pem") # Dev only: skip verification queue3 = Bunqueue.queue("jobs", host: "localhost", port: 6789, tls: true, verify: false) ``` Workers accept the same TLS options as queues in every SDK (in the Bun client, under `connection.tls`). The wire protocol is unchanged, TLS only wraps the transport. From the CLI: ```bash bunqueue stats --host queue.example.com --tls # system CAs bunqueue stats --tls-ca ./ca.pem # custom CA bunqueue stats --tls-no-verify # self-signed, dev only ``` ## Self-signed certificate (dev / internal networks) No public domain? Generate your own cert: ```bash openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout key.pem -out cert.pem \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" ``` Clients then connect with the CA-file option pointing at `cert.pem` (`caFile` in TypeScript and PHP, `ca_file` in Python, Rust, and Elixir, `CAFile` in Go): the self-signed cert acts as its own CA. That keeps full verification, no verification opt-out needed. ## Good to know - Certificate verification is on by default in every SDK: the client rejects untrusted or mismatched server certs unless you explicitly opt out (`rejectUnauthorized: false` in TypeScript, `{"verify": False}` in Python, `['verifyPeer' => false]` in PHP, `InsecureSkipVerify: true` in Go, `verify: false` in Elixir), encryption without authentication, dev only. Rust exposes no insecure mode at all. - TLS encrypts traffic but does not identify clients. Combine it with [auth tokens](/guide/env-vars/) for servers exposed beyond localhost. - A TLS-enabled server only accepts TLS clients; plaintext clients fail the handshake immediately (they do not hang). - HTTP endpoints (`/health`, dashboards, `/ws`, `/events`) are served over `https://`/`wss://` when TLS is enabled. --- # Monitoring: Prometheus, Grafana & Health Checks Watch bunqueue in production. Prometheus metrics, a ready-made Grafana dashboard, alert rules, and Kubernetes health probes. URL: https://bunqueue.dev/guide/monitoring/
server · monitoring

Prometheus, probes, live events.

The bunqueue server exposes everything a production setup needs to watch it: a Prometheus metrics endpoint, health probes for Kubernetes, ready-made alert rules, and a Grafana dashboard.

## Quick Start The fastest way to see it all: bunqueue ships a pre-configured monitoring stack. ```bash # Start bunqueue + Prometheus + Alertmanager + Grafana docker compose --profile monitoring up -d ``` - **Grafana**: http://localhost:3000 (admin/bunqueue) - **Prometheus**: http://localhost:9090 - **Alertmanager**: http://localhost:9093 The bundled versions are pinned for reproducible deployments. `admin/bunqueue` is a local demo credential. The Compose profile binds all three monitoring UIs to `127.0.0.1`; set `GRAFANA_ADMIN_PASSWORD` to a unique secret and put any intentional remote access behind authentication/TLS. The default Alertmanager receiver is local-only and sends no external notifications until you configure one. The bundled Grafana service disables suggested-plugin preinstallation, update checks, and anonymous usage reporting, so startup is deterministic and does not make background catalog or telemetry requests. Add and pin any plugins you need explicitly in your own deployment. Already running Prometheus? Just point it at the metrics endpoint: ```yaml # prometheus.yml scrape_configs: - job_name: 'bunqueue' scrape_interval: 5s static_configs: - targets: ['localhost:6790'] metrics_path: /prometheus ``` ## Prometheus Endpoint Metrics live at `/prometheus` on the HTTP port (default 6790): ```bash curl http://localhost:6790/prometheus ``` The endpoint returns Prometheus text format 0.0.4 with an explicit content type and trailing newline, which Prometheus 3 requires. It is unauthenticated by default so scrapers work out of the box. Set `METRICS_AUTH=true` to require a bearer token from `AUTH_TOKENS`, then add `bearer_token: 'your-auth-token'` to the scrape config. If auth is required but `AUTH_TOKENS` is empty, the endpoint fails closed with `503` rather than becoming public. Per-queue gauges read the active backend projection. With PostgreSQL, each broker exposes its local projection, which converges after committed events via `LISTEN` plus polling repair; scrape every broker or aggregate them according to your deployment topology. ### Server-wide metrics | Metric | Type | Description | |--------|------|-------------| | `bunqueue_jobs_waiting` | gauge | Jobs waiting in queue | | `bunqueue_jobs_prioritized` | gauge | Prioritized jobs (priority > 0) | | `bunqueue_jobs_delayed` | gauge | Delayed jobs | | `bunqueue_jobs_active` | gauge | Jobs being processed | | `bunqueue_jobs_completed` | gauge | Retained completed jobs visible to this broker or manager | | `bunqueue_jobs_dlq` | gauge | Jobs in the dead letter queue | | `bunqueue_jobs_pushed_total` | counter | Total jobs pushed | | `bunqueue_jobs_pulled_total` | counter | Total jobs pulled | | `bunqueue_jobs_completed_total` | counter | Total jobs completed | | `bunqueue_jobs_failed_total` | counter | Total jobs failed | | `bunqueue_uptime_seconds` | gauge | Server uptime | | `bunqueue_cron_jobs_registered` | gauge | Cron jobs registered in this broker's local scheduler projection | | `bunqueue_workers_registered` | gauge | Registered workers | | `bunqueue_workers_active` | gauge | Active workers | | `bunqueue_worker_active_jobs` | gauge | Jobs currently held by registered workers | | `bunqueue_worker_concurrency_slots` | gauge | Configured worker concurrency capacity | | `bunqueue_workers_processed_total` | counter | Jobs processed by workers | | `bunqueue_workers_failed_total` | counter | Jobs failed by workers | | `bunqueue_webhooks_registered` | gauge | Registered webhooks | | `bunqueue_webhooks_enabled` | gauge | Enabled webhooks | | `bunqueue_storage_degraded` | gauge | Persistent storage is degraded (0/1) | | `bunqueue_storage_disk_full` | gauge | SQLite reported a full disk (0/1) | | `bunqueue_sqlite_database_size_bytes` | gauge | SQLite main-file size (persistent mode only) | | `bunqueue_process_heap_used_bytes` | gauge | Process heap currently used | | `bunqueue_process_heap_total_bytes` | gauge | Process heap allocation | | `bunqueue_process_resident_memory_bytes` | gauge | Resident set size | | `bunqueue_build_info{version,bun_version}` | gauge | Server and Bun runtime identity | | `bunqueue_connections{transport}` | gauge | Current TCP, WebSocket, and SSE connections | | `process_cpu_seconds_total` | counter | Standard process CPU time collector | | `process_start_time_seconds` | gauge | Standard process start timestamp | | `process_resident_memory_bytes` | gauge | Standard process resident memory collector | | `process_heap_bytes` | gauge | Standard process heap collector | In PostgreSQL mode, job-state gauges and lifecycle totals come from the local backend projection and converge after committed events plus polling repair. Cron, worker, webhook, backup, process, runtime, and connection families describe the specific broker being scraped; aggregate or retain the broker label when scraping a fleet. ### Per-queue metrics Five gauges carry a `queue` label so you can filter and aggregate per queue: `bunqueue_queue_jobs_waiting`, `bunqueue_queue_jobs_prioritized`, `bunqueue_queue_jobs_delayed`, `bunqueue_queue_jobs_active`, and `bunqueue_queue_jobs_dlq`. ``` bunqueue_queue_jobs_waiting{queue="emails"} 30 bunqueue_queue_jobs_waiting{queue="payments"} 12 bunqueue_queue_jobs_active{queue="emails"} 5 ``` Per-queue output is capped at 100 queue names by default because every unique label value creates five time series per server. Configure `METRICS_MAX_QUEUES`, or `telemetry.maxPrometheusQueues` in the config file; `0` disables labelled per-queue metrics while global totals remain available. `bunqueue_queue_metrics_exported` and `bunqueue_queue_metrics_omitted` make a capped view explicit. Never embed job, user, request, or tenant IDs in queue names. ### Backup metrics Scheduled S3 backup exports zero-initialized, label-free metrics: | Metric | Type | Description | |--------|------|-------------| | `bunqueue_backup_enabled` | gauge | Scheduled backup is enabled | | `bunqueue_backup_scheduler_running` | gauge | The scheduler timer is active | | `bunqueue_backup_in_progress` | gauge | One backup attempt is active | | `bunqueue_backup_interval_seconds` | gauge | Configured schedule interval | | `bunqueue_backup_retention` | gauge | Configured retained backup count | | `bunqueue_backup_attempts_total` | counter | Attempts actually started | | `bunqueue_backup_successes_total` | counter | Successful attempts | | `bunqueue_backup_failures_total` | counter | Failed attempts | | `bunqueue_backup_overlap_rejections_total` | counter | Requests rejected while an attempt was active | | `bunqueue_backup_consecutive_failures` | gauge | Failures since the last success | | `bunqueue_backup_last_success_timestamp_seconds` | gauge | Unix timestamp of the last success, or 0 | | `bunqueue_backup_last_failure_timestamp_seconds` | gauge | Unix timestamp of the last failure, or 0 | | `bunqueue_backup_last_duration_seconds` | gauge | Duration of the last attempt | | `bunqueue_backup_last_size_bytes` | gauge | Compressed size of the last successful backup | Calculate freshness in PromQL from the timestamp: ```text time() - bunqueue_backup_last_success_timestamp_seconds ``` ### Latency histograms Push, pull, and ack latency are exposed as Prometheus histograms: `bunqueue_push_duration_seconds`, `bunqueue_pull_duration_seconds`, and `bunqueue_ack_duration_seconds`, each with `_bucket`, `_sum`, and `_count` series. Use them for p99 alerts: ```text histogram_quantile(0.99, sum by (le) (rate(bunqueue_push_duration_seconds_bucket[5m]))) ``` See [Built-in Telemetry](/guide/telemetry/) for bucket boundaries and details. ## Health Endpoints Kubernetes-compatible probes, no auth, no rate limit: ```bash curl http://localhost:6790/health # detailed health with memory stats curl http://localhost:6790/healthz # liveness probe (alias: /live), plain "OK" curl http://localhost:6790/ready # readiness probe ``` `/health` reports per-state job counts, connections, memory, uptime, and version: ```json { "ok": true, "status": "healthy", "uptime": 3600, "version": "x.y.z", "queues": { "waiting": 42, "active": 8, "delayed": 3, "completed": 120, "dlq": 0 }, "connections": { "tcp": 0, "ws": 1, "sse": 0 }, "memory": { "heapUsed": 45, "heapTotal": 80, "rss": 210 } } ``` When the disk fills up, `/health` returns `503`, `ok` flips to `false`, `status` becomes `"degraded"`, and a `storage` block appears with `diskFull: true`, the underlying error, and a `since` timestamp. `/ready` also returns `503`; `/healthz` stays a pure liveness signal. The connection block reports the real TCP, WebSocket, and SSE counts. ## Alert Rules Pre-configured Prometheus alerts ship in `monitoring/alert_rules.yml`: | Alert | Condition | Severity | |-------|-----------|----------| | `BunqueueDLQHigh` | DLQ > 100 for 5m | critical | | `BunqueueHighFailureRate` | Failure > 5% for 5m | warning | | `BunqueueQueueBacklog` | Waiting + prioritized > 10k for 10m | warning | | `BunqueueNoWorkers` | 0 active workers + ready backlog | critical | | `BunqueueServerDown` | Server unreachable | critical | | `BunqueueStorageDegraded` | Persistent storage degraded for 1m | critical | | `BunqueueBackupSchedulerDown` | Backup enabled but scheduler inactive | critical | | `BunqueueBackupStale` | No success within two configured intervals | critical | | `BunqueueBackupFailures` | Failed attempt in the last 15m | warning | | `BunqueueQueueMetricsOmitted` | Per-queue cardinality cap reached | warning | | `BunqueueLowThroughput` | < 1 completion/s while work arrives and backlog exists | warning | | `BunqueueWorkerOverload` | Active jobs / concurrency slots > 95% | warning | | `BunqueueJobsStuck` | Active jobs, no completions | warning | Each rule looks like this; copy and tune the thresholds for your workload: ```yaml - alert: BunqueueDLQHigh expr: bunqueue_jobs_dlq > 100 for: 5m labels: severity: critical annotations: summary: "High number of jobs in DLQ" description: "{{ $value }} jobs are in the dead letter queue." ``` ## Grafana Dashboard The bundled dashboard (`monitoring/grafana/dashboards/bunqueue.json`) covers server/storage status, job counts, throughput, a multi-select queue filter, per-queue breakdowns, p50/p95/p99 latency and a seconds-based heatmap, active-worker capacity/utilization, process memory, SQLite size, webhooks, cron, connections, backup freshness/outcomes, omitted queue count, and firing-alert indicators. The docker compose stack loads it automatically. To import it into an existing Grafana: Dashboards → Import → upload the JSON → select your Prometheus datasource. ## CLI and Debug Access ```bash bunqueue metrics # Prometheus text format from the terminal bunqueue stats # human-readable server stats bunqueue stats --json # same, as JSON ``` For troubleshooting there are two debug endpoints (both require a bearer token when `AUTH_TOKENS` is set): ```bash curl http://localhost:6790/heapstats # heap object breakdown curl -X POST http://localhost:6790/gc # force garbage collection ``` ## Logging Configure log level and format at startup via environment variables or the [config file](/guide/configuration/): ```bash LOG_LEVEL=debug bun run src/main.ts # debug, info, warn, error (default: info) LOG_FORMAT=json bun run src/main.ts # structured JSON output for log shippers ``` ## Best Practices 1. **Scrape interval**: 5-15 seconds gives near-real-time visibility 2. **Alerts**: start with the included rules, tune thresholds for your workload 3. **Per-queue dashboards**: filter with the `{queue="..."}` label 4. **Cardinality**: keep `METRICS_MAX_QUEUES` bounded and alert when queues are omitted 5. **Backup freshness**: page on a stopped scheduler or no success within two intervals 6. **Latency SLOs**: alert on histogram quantiles, e.g. `histogram_quantile(0.99, sum by (le) (rate(bunqueue_push_duration_seconds_bucket[5m]))) > 0.05` 7. **Throughput**: the `/stats` endpoint exposes live `pushPerSec` / `pullPerSec` rates, see [Built-in Telemetry](/guide/telemetry/) :::tip[Related Guides] - [Built-in Telemetry](/guide/telemetry/) - What is measured and how to read it - [Troubleshooting](/troubleshooting/) - Diagnose common issues - [Production Deployment](/guide/deployment/) - Deploy with monitoring ::: --- # Telemetry: Latency & Throughput Out of the Box bunqueue measures its own latency and throughput with no setup. Prometheus histograms, live per-second rates, and structured logs. URL: https://bunqueue.dev/guide/telemetry/
server · telemetry

Telemetry that sees inside the process.

Every push, pull, and ack is timed and counted automatically. You get latency histograms, live throughput rates, and structured logs without writing any instrumentation code.

This page explains what bunqueue measures and where to read each number. For the full metric list, scrape config, dashboards, and alert rules, see [Monitoring](/guide/monitoring/). ## Latency Histograms Every push, pull, and ack operation is timed and recorded in a Prometheus histogram (a set of counters that tracks how many operations fell under each duration threshold, which lets you compute percentiles later): | Metric | Description | |--------|-------------| | `bunqueue_push_duration_seconds` | Time to push a job | | `bunqueue_pull_duration_seconds` | Time to pull a job from a queue | | `bunqueue_ack_duration_seconds` | Time to acknowledge a completed job | Each exposes `_bucket`, `_sum`, and `_count` series on `/prometheus`, with bucket boundaries in seconds at `0.0001, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`. ```bash curl http://localhost:6790/prometheus ``` Compute percentiles in Prometheus with `histogram_quantile()`: ```text # p99 push latency histogram_quantile(0.99, sum by (le) (rate(bunqueue_push_duration_seconds_bucket[5m]))) # p50 pull latency histogram_quantile(0.50, sum by (le) (rate(bunqueue_pull_duration_seconds_bucket[5m]))) ``` Averages can be derived from `_sum / _count` and are in seconds in Prometheus. Latency averages returned by the `Metrics` TCP command remain in milliseconds (`avgLatencyMs` and `avgProcessingMs`); the unit change only corrects Prometheus exposition. The `bunqueue metrics` CLI prints Prometheus text, while the HTTP `/metrics` endpoint only exposes the `total*` counters. ## Throughput Rates The server tracks live per-second rates using an exponential moving average (a smoothing technique that favors recent activity, so the number reacts quickly without jitter): | Rate | Description | |------|-------------| | `pushPerSec` | Jobs pushed per second | | `pullPerSec` | Jobs pulled per second | | `completePerSec` | Jobs completed (acked) per second | | `failPerSec` | Jobs failed per second | Read them from the `/stats` HTTP endpoint: ```bash curl http://localhost:6790/stats ``` ```json { "ok": true, "stats": { "waiting": 120, "active": 8, "pushPerSec": 12500, "pullPerSec": 12480, "completePerSec": 12460, "failPerSec": 2 } } ``` The real response's `stats` object also includes `prioritized`, `delayed`, `dlq`, `completed`, `waiting-children`, `uptime`, the `total*` counters (failure counts appear only as `totalFailed`/`failPerSec` — there is no `failed` field), and `cronJobs`/`cronPending`; `memory` and `collections` are top-level siblings of `stats`. The example is trimmed to the rate fields. These rates are not part of the `/prometheus` output. In Prometheus, derive rates from the counters instead: ```text rate(bunqueue_jobs_pushed_total[5m]) ``` ## Per-Queue Drill-Down The per-queue gauges (`bunqueue_queue_jobs_waiting{queue="..."}` and friends, listed in [Monitoring](/guide/monitoring/#per-queue-metrics)) let you build per-queue dashboards and alerts: ```text bunqueue_queue_jobs_waiting{queue="emails"} # backlog of one queue sum(bunqueue_queue_jobs_active) # active jobs across all queues topk(5, bunqueue_queue_jobs_waiting) # top 5 queues by backlog ``` Programmatically, in embedded mode: ```typescript const perQueue = queueManager.getPerQueueStats(); // Map ``` Prometheus exposition is capped independently from this embedded API: `METRICS_MAX_QUEUES=100` is the default, `0` disables queue labels, and `bunqueue_queue_metrics_exported + bunqueue_queue_metrics_omitted` always equals the registered queue count at scrape time. This prevents an accidental queue-per-tenant naming scheme from creating an unbounded live scrape. ## Runtime and Recovery Signals Generic process dashboards can use the standard `process_cpu_seconds_total`, `process_start_time_seconds`, `process_resident_memory_bytes`, and `process_heap_bytes` collectors. `bunqueue_build_info` identifies both the bunqueue and Bun runtime versions, and `bunqueue_connections` uses the bounded `tcp`, `websocket`, and `sse` transport values. When scheduled S3 backup exists, its attempts, successes, failures, overlap rejections, current state, last duration/size, and last success/failure timestamps are exported without dynamic labels. See [Monitoring](/guide/monitoring/#backup-metrics) for the exact families and [S3 Backup](/guide/backup/) for recovery semantics. ## Log Levels Set verbosity with `LOG_LEVEL` (`debug`, `info`, `warn`, `error`; default `info`) and switch to structured JSON with `LOG_FORMAT=json`: ```bash LOG_LEVEL=warn LOG_FORMAT=json bun run src/main.ts ``` Messages below the configured level are dropped. The internal `Logger` is not a public package export, so configure logging via env vars or the [config file](/guide/configuration/). ## Feeding Other Platforms bunqueue speaks two universal formats: Prometheus metrics on `/prometheus` and JSON logs on stdout. Anything that can scrape Prometheus or ship stdout can consume them. - **Metrics**: Prometheus and Victoria Metrics scrape directly; Grafana Cloud via Alloy/Agent; Datadog via the `openmetrics` check; New Relic, Axiom, and Chronosphere via Prometheus remote write; Splunk Observability via an OpenTelemetry Collector with the Prometheus receiver. - **Logs** (`LOG_FORMAT=json`): Loki via Promtail/Alloy, ELK via Filebeat, Datadog Agent, Splunk forwarder, CloudWatch Agent, or any shipper that reads stdout. Example, Datadog agent config: ```yaml # conf.d/openmetrics.d/conf.yaml instances: - prometheus_url: http://localhost:6790/prometheus namespace: bunqueue metrics: - bunqueue_* ``` :::note[OpenTelemetry] bunqueue does not ship a native OpenTelemetry SDK, OTLP exporter, or distributed tracing. To feed an OpenTelemetry pipeline, scrape `/prometheus` with an OpenTelemetry Collector using the Prometheus receiver. ::: :::tip[Related Guides] - [Monitoring](/guide/monitoring/) - Full metric reference, Grafana dashboard, alert rules - [Environment Variables](/guide/env-vars/) - LOG_LEVEL, LOG_FORMAT, METRICS_AUTH ::: --- # Integrations: Web Frameworks, Databases, AI Agents How bunqueue fits your stack: background jobs in Hono and Elysia, storage options, and AI agent control over MCP. Start here, then follow the detailed guides. URL: https://bunqueue.dev/guide/integrations/
guide · integrations

bunqueue in your stack.

One pattern works everywhere: create a queue, add jobs from your HTTP handlers, process them in a worker. This page shows the smallest version, then points you to the detailed guides.

This page is the hub for integrations. It shows the one pattern every integration shares, then links to the framework, storage, and AI agent guides. ## The smallest integration bunqueue in **embedded mode** runs inside your app's process. Set `dataPath` to back it with a local SQLite file, so there is no queue server to install or run. This works in any Bun app, whatever framework you use: ```typescript import { Queue, Worker } from 'bunqueue/client'; // The queue: where jobs wait const storage = { embedded: true, dataPath: './data/bunq.db' } as const; const emails = new Queue('emails', storage); // The worker: runs your function on each job new Worker( 'emails', async (job) => { await sendEmail(job.data); }, storage ); // Anywhere in your app (an HTTP handler, for example): await emails.add('welcome', { to: 'user@example.com' }); ``` The HTTP response returns immediately; the email is sent in the background, with automatic retries if it fails. :::caution[Embedded mode required for in-process queues] All framework examples use `embedded: true`. Without it, bunqueue tries to connect to a TCP server instead. If you do run a [standalone server](/guide/server/), drop `embedded: true` and pass `connection: { host, port }`. ::: ## Web frameworks The pattern above plus each framework's idioms (typed context, validation, plugins): | Framework | What the guide adds | Guide | | ------------------------------ | --------------------------------------------------- | ------------------------------------ | | [Hono](https://hono.dev) | Routes, job status endpoints, typed middleware | [Hono Integration](/guide/hono/) | | [Elysia](https://elysiajs.com) | Schema validation with `t.Object()`, plugin pattern | [Elysia Integration](/guide/elysia/) | Using another framework? The smallest example above works as is; only the routing syntax changes. ## Databases bunqueue needs no external database in its default memory/SQLite modes. For a standalone fleet, PostgreSQL 15–18 is an optional database-authoritative backend that coordinates multiple brokers; MySQL is not supported. See [Storage backends](/guide/databases/) for both topologies and ephemeral-host patterns. ## AI agents (MCP) bunqueue ships an MCP server, so AI agents like Claude can add jobs, manage crons, retry failures, and monitor queues directly: ```bash claude mcp add bunqueue -- bunx --package=bunqueue bunqueue-mcp ``` The same command shape works for Claude Desktop, Cursor, Windsurf, and any MCP client over stdio. Setup for each client, plus the full tool list, is in the [MCP Server guide](/guide/mcp/). :::note `bunqueue-mcp` is a binary inside the `bunqueue` package, not a separate npm package. The MCP SDK is an optional peer dependency: run `bun add @modelcontextprotocol/sdk` once before starting the MCP server. ::: ## Shared patterns These apply to any framework. ### Define queues in one module Create each queue once at startup and import it where needed. Do not create a `new Queue(...)` inside a request handler. ```typescript // queues.ts import { Queue } from 'bunqueue/client'; export const queues = { emails: new Queue('emails', { embedded: true, dataPath: './data/bunq.db', defaultJobOptions: { attempts: 3, backoff: 5000 }, }), reports: new Queue('reports', { embedded: true, dataPath: './data/bunq.db', defaultJobOptions: { timeout: 300_000 }, }), } as const; ``` `defaultJobOptions` sets the retry and timeout defaults for every job added to that queue; per-job options override them. ### Graceful shutdown On shutdown, close workers first (they wait for active jobs to finish), then release the embedded queue manager: ```typescript import { shutdownManager } from 'bunqueue/client'; async function shutdown() { await Promise.all(workers.map((w) => w.close())); shutdownManager(); process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); ``` ## Next steps - [Hono Integration](/guide/hono/) - Routes, workers, and status endpoints - [Elysia Integration](/guide/elysia/) - Validation and the plugin pattern - [Storage backends](/guide/databases/) - SQLite and PostgreSQL topologies - [MCP Server](/guide/mcp/) - Full AI agent setup - [CPU-Intensive Workers](/guide/cpu-intensive-workers/) - Heavy jobs without dropped connections --- # Bunqueue + Hono: Background Jobs in a Bun Web App Add background jobs to a Hono app with bunqueue: enqueue from routes, process in a worker, expose job status endpoints, and shut down cleanly. URL: https://bunqueue.dev/guide/hono/
guide · hono

Background jobs for Hono.

Return the HTTP response now, do the slow work later. This guide wires bunqueue into Hono: enqueue from routes, process in a worker, check job status, shut down cleanly.

This guide shows how to run background jobs, work your server does after the HTTP response is sent, inside a Hono app. Everything runs in one process using bunqueue's **embedded mode**, configured with `dataPath` so jobs are stored in a local SQLite file instead of a separate queue server. :::note[Runtime] This guide runs Hono on Bun with the Bun `bunqueue` package; embedded mode is Bun-only. Running Hono on Node.js or Deno? Start a bunqueue server and use `bunqueue-client` instead, the route and worker code is otherwise the same (see [SDKs](/guide/sdks/)). ::: ## Minimal working app Copy, run with `bun run app.ts`, done: ```typescript import { Hono } from 'hono'; import { Queue, Worker, shutdownManager } from 'bunqueue/client'; // Queue: where jobs wait. Worker: runs your function on each job. const storage = { embedded: true, dataPath: './data/bunq.db' } as const; const emails = new Queue('emails', storage); new Worker( 'emails', async (job) => { console.log('sending to', job.data.to); // await sendEmail(job.data); return { sent: true }; }, { ...storage, concurrency: 3 } ); // 3 jobs in parallel const app = new Hono(); app.post('/api/send-email', async (c) => { const body = await c.req.json(); const job = await emails.add('send', body, { attempts: 3, // retry up to 3 times on failure backoff: 5000, // wait 5s (then longer) between retries }); return c.json({ queued: true, jobId: job.id }); }); process.on('SIGINT', () => { shutdownManager(); process.exit(0); }); export default app; ``` The route responds immediately. The worker sends the email in the background and retries automatically if it throws. :::caution[Embedded mode required] Every example uses `embedded: true`. Without it, bunqueue tries to connect to a TCP server. Also create each `Queue` once at module level, not inside a request handler. ::: ## Common tasks ### Let clients check job status Return the job id from the enqueue route, then expose a status endpoint. `job.progress` is a 0-100 number your worker sets, `returnvalue` is what your worker returned, `failedReason` is the last error message: ```typescript app.get('/api/jobs/:id', async (c) => { const job = await emails.getJob(c.req.param('id')); if (!job) return c.json({ error: 'Job not found' }, 404); return c.json({ id: job.id, name: job.name, progress: job.progress, result: job.returnvalue ?? null, error: job.failedReason ?? null, }); }); ``` ### Report progress from the worker ```typescript new Worker( 'reports', async (job) => { await job.updateProgress(10, 'Fetching data'); const data = await fetchData(job.data); await job.updateProgress(80, 'Rendering PDF'); const url = await renderPdf(data); return { url }; }, { embedded: true, dataPath: './data/bunq.db' } ); ``` ### Expose queue stats `getJobCounts()` is synchronous in embedded mode and returns counts per state (`waiting`, `active`, `completed`, `failed`, `delayed`): ```typescript app.get('/api/queues/emails/stats', (c) => c.json(emails.getJobCounts())); ``` ### React to job results ```typescript const worker = new Worker('emails', processor, { embedded: true, dataPath: './data/bunq.db', }); worker.on('completed', (job, result) => console.log('done', job.id)); worker.on('failed', (job, err) => console.error('failed', job.id, err.message)); ``` ### Share queues through typed middleware For larger apps, put queues on Hono's context so every route gets them typed: ```typescript import { Hono } from 'hono'; import type { MiddlewareHandler } from 'hono'; import { Queue } from 'bunqueue/client'; const queues = { emails: new Queue('emails', { embedded: true, dataPath: './data/bunq.db' }), reports: new Queue('reports', { embedded: true, dataPath: './data/bunq.db' }), }; type Env = { Variables: { queues: typeof queues } }; const queueMiddleware: MiddlewareHandler = async (c, next) => { c.set('queues', queues); await next(); }; const app = new Hono(); app.use('*', queueMiddleware); app.post('/api/reports', async (c) => { const job = await c.get('queues').reports.add('generate', await c.req.json()); return c.json({ jobId: job.id }); }); ``` ### Run workers in a separate process In production you often want the web server and workers to scale and restart independently. Start one bunqueue server as the sole owner of the SQLite file, then connect both processes over TCP: ```bash bunqueue start --data-path ./data/bunq.db ``` Use the same connection in the Hono app's `Queue` instances and the worker: ```typescript // In the Hono app import { Queue } from 'bunqueue/client'; const connection = { host: '127.0.0.1', port: 6789 }; const queues = { emails: new Queue('emails', { connection }), reports: new Queue('reports', { connection }), }; ``` ```typescript // worker-process.ts (run with: bun run worker-process.ts) import { Worker } from 'bunqueue/client'; const connection = { host: '127.0.0.1', port: 6789 }; const worker = new Worker( 'emails', async (job) => { // ... process job return { success: true }; }, { connection, concurrency: 5 } ); process.on('SIGTERM', async () => { await worker.close(); // waits for active jobs to finish process.exit(0); }); ``` :::note Never point two embedded processes at the same SQLite file. Multiple clients can share one SQLite-backed server over TCP; when several active broker processes must share the queue, use the [PostgreSQL backend](/guide/databases/). ::: ### Shut down cleanly Close workers first (each `close()` waits for its active jobs), then release the embedded manager: ```typescript import { shutdownManager } from 'bunqueue/client'; async function shutdown() { await Promise.all(workers.map((w) => w.close())); shutdownManager(); process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); ``` ## Gotchas - **One `Queue` instance per queue name, created at startup.** Creating queues inside handlers works but wastes memory and setup time on every request. - **Long jobs need a `timeout`.** The default processing timeout comes from the job options; set `timeout: 300_000` for a 5 minute report job so it is not killed early. - **CPU-heavy processors block the event loop**, the single thread Bun uses for all I/O. See [CPU-Intensive Workers](/guide/cpu-intensive-workers/) for yield patterns. :::tip[Related] - [Elysia Integration](/guide/elysia/) - Same pattern with schema validation - [Integrations Overview](/guide/integrations/) - All integrations - [Worker API](/guide/worker/) - Every worker option explained ::: --- # Elysia: Validated Background Jobs for Bun Add background jobs to an Elysia app with bunqueue: validated enqueue routes with t.Object, a shared queue plugin, DLQ monitoring, and clean shutdown. URL: https://bunqueue.dev/guide/elysia/
guide · elysia

Background jobs for Elysia.

Elysia validates request bodies before your handler runs, so jobs enter the queue already type-checked. This guide shows the Elysia-specific pieces: validated routes, the plugin pattern, and failure monitoring.

This guide wires bunqueue into Elysia. Everything runs in one process using **embedded mode**, configured with `dataPath` so jobs are stored in a local SQLite file, with no separate queue server. The general patterns (status endpoints, separate worker processes, shutdown) are the same in every framework and live in the [Hono guide](/guide/hono/) and the [Integrations overview](/guide/integrations/); this page keeps to what Elysia does differently. :::note[Runtime] Elysia is a Bun framework, so this guide uses the Bun `bunqueue` package end to end; embedded mode is not available in the polyglot [SDKs](/guide/sdks/). ::: ## Minimal working app Copy, run with `bun run app.ts`: ```typescript import { Elysia, t } from 'elysia'; import { Queue, Worker } from 'bunqueue/client'; interface EmailJob { to: string; subject: string; body: string } // Queue: where jobs wait. Worker: runs your function on each job. const storage = { embedded: true, dataPath: './data/bunq.db' } as const; const emails = new Queue('emails', storage); new Worker('emails', async (job) => { console.log('sending to', job.data.to); // await sendEmail(job.data); return { sent: true }; }, { ...storage, concurrency: 3 }); // 3 jobs in parallel new Elysia() .post('/emails', async ({ body }) => { const job = await emails.add('send', body); return { jobId: job.id, status: 'queued' }; }, { // Elysia validates the body BEFORE your handler runs, // so `body` is already typed and bad payloads never reach the queue. body: t.Object({ to: t.String({ format: 'email' }), subject: t.String({ minLength: 1 }), body: t.String(), }), }) .listen(3000); ``` The route responds immediately; the worker processes the job in the background and retries on failure (3 attempts by default). :::caution[Embedded mode required] Every example uses `embedded: true`. Without it, bunqueue tries to connect to a TCP server. Create each `Queue` once at module level, not inside a handler. ::: ## Common tasks ### Job status endpoint ```typescript .get('/jobs/:id', async ({ params }) => { const job = await emails.getJob(params.id); if (!job) return { error: 'Job not found' }; return { id: job.id, progress: job.progress, // 0-100, set by the worker result: job.returnvalue ?? null, // what the worker returned error: job.failedReason ?? null, // last error message }; }, { params: t.Object({ id: t.String() }) }) ``` ### Share queues with a plugin Elysia's idiom for shared state is a plugin. `decorate` puts the queues on the context, `derive` adds a small typed helper: ```typescript import { Elysia } from 'elysia'; import { Queue } from 'bunqueue/client'; export const queuePlugin = new Elysia({ name: 'queue' }) .decorate('queues', { emails: new Queue('emails', { embedded: true, dataPath: './data/bunq.db' }), reports: new Queue('reports', { embedded: true, dataPath: './data/bunq.db' }), }) .derive(({ queues }) => ({ enqueue: (queue: keyof typeof queues, name: string, data: T) => queues[queue].add(name, data), })); // Usage const app = new Elysia() .use(queuePlugin) .post('/api/notify', async ({ body, enqueue }) => { const job = await enqueue('emails', 'send', body); return { jobId: job.id }; }); ``` ### Monitor failed jobs (DLQ) The DLQ (dead letter queue) holds jobs that failed all their retries, with the error preserved. Expose it so you can see and replay failures: ```typescript .get('/dlq/emails', () => ({ stats: emails.getDlqStats(), entries: emails.getDlq().slice(0, 10).map((e) => ({ jobId: e.job.id, error: e.error, enteredAt: new Date(e.enteredAt).toISOString(), })), })) .post('/dlq/emails/retry', () => ({ retried: emails.retryDlq(), // re-queues every DLQ entry })) ``` You can also let bunqueue retry the DLQ on a schedule: ```typescript emails.setDlqConfig({ autoRetry: true, autoRetryInterval: 300_000, // try again every 5 minutes maxAutoRetries: 3, }); ``` See the [Dead Letter Queue guide](/guide/dlq/) for the full options. ### Health check with queue counts `getJobCounts()` is synchronous in embedded mode: ```typescript .get('/health', () => ({ status: 'ok', queues: { emails: emails.getJobCounts() }, })) ``` ### Shut down cleanly Same as every framework: close workers (each waits for its active jobs), then release the embedded manager: ```typescript import { shutdownManager } from 'bunqueue/client'; process.on('SIGTERM', async () => { await worker.close(); shutdownManager(); process.exit(0); }); ``` ## Gotchas - **Validate at the edge, trust in the worker.** With `t.Object` on the route, your worker can assume `job.data` matches the schema. Without it, validate inside the processor too, a bad payload will fail all retries and land in the DLQ. - **One `Queue` instance per queue name**, created at startup. New instances per request waste memory. - **CPU-heavy processors block the event loop**, the single thread that serves all requests. See [CPU-Intensive Workers](/guide/cpu-intensive-workers/). :::tip[Related] - [Hono Integration](/guide/hono/) - Same pattern, plus separate worker processes and progress reporting - [Integrations Overview](/guide/integrations/) - Shared patterns for any framework - [Queue API](/guide/queue/) - Every queue method explained ::: --- # IoT & Edge: Buffer Locally, Forward to the Center Run bunqueue on edge gateways (Raspberry Pi, ARM64). Bridge MQTT sensors to a persisted job queue, buffer offline, and forward to a central server over TLS. URL: https://bunqueue.dev/guide/iot-edge/
guide · iot & edge

Queue at the edge, drain to the center.

This page shows how to run bunqueue on an edge gateway: turn MQTT sensor messages into persisted jobs, keep them safe while the uplink is down, and forward them to a central server when it comes back.

bunqueue fits where a Redis + BullMQ stack does not: a single Bun process with one SQLite file, running on the gateway next to your sensors. No containers, no broker for the queue itself. :::note[Runtime] Everything on this page uses the Bun `bunqueue` package: embedded mode and `queue.forward()` are Bun-only. The polyglot [SDKs](/guide/sdks/) are not affected, they can still produce and consume on the central server the gateway forwards to. ::: ## Is it the right fit? | Scenario | Fit | | ---------------------------------------------------------- | ------------------------------------------------------------------ | | Edge gateway (Raspberry Pi 4/5, Jetson, ARM64/x64 mini-PC) | Yes, embedded queue plus store-and-forward | | Backend telemetry ingestion (absorb bursts, retry, DLQ) | Yes | | Offline-first buffering over a flaky uplink | Yes, jobs persist to SQLite on the gateway | | Replacing an MQTT broker | No, keep Mosquitto/EMQX and bridge into bunqueue | | Running directly on microcontrollers (ESP32, 32-bit ARM) | No, Bun needs ARM64/x64; those devices publish MQTT to the gateway | The pattern that works: ``` sensors ──MQTT──► broker (Mosquitto/EMQX) ──► bridge ──► bunqueue ──► Worker (SQLite) │ ▼ backend / TSDB / alerts ``` Devices keep speaking MQTT, their native protocol. A small bridge script subscribes to topics and turns each message into a job. From there you get what a broker alone does not give you: retries with backoff, a dead letter queue (DLQ, a parking lot for messages that failed all retries), priorities, delayed jobs, and a durable buffer when the uplink is down. ## The MQTT bridge A full runnable version lives in [`examples/mqtt-bridge/`](https://github.com/egeominotti/bunqueue/tree/main/examples/mqtt-bridge). The core is this: ```typescript import mqtt from 'mqtt'; import { Queue, Worker } from 'bunqueue/client'; // Embedded mode: the queue runs inside this process, no server needed, // persisted to a SQLite file on the gateway. const queue = new Queue('telemetry', { embedded: true, dataPath: './edge-queue.db', }); const client = mqtt.connect('mqtt://localhost:1883'); client.on('connect', () => client.subscribe('sensors/#')); client.on('message', (topic, payload) => { void queue.add( 'reading', { topic, payload: JSON.parse(payload.toString()), receivedAt: Date.now() }, { attempts: 5 } ); }); // Process locally, or POST to your backend const worker = new Worker( 'telemetry', async (job) => { // write to TSDB, trigger alerts, forward... return { processed: true }; }, { embedded: true, dataPath: './edge-queue.db', concurrency: 10 } ); ``` Run it: ```bash bun add mqtt MQTT_URL=mqtt://localhost:1883 bun examples/mqtt-bridge/index.ts # publish a test reading mosquitto_pub -t sensors/temp/room1 -m '{"temp":21.5}' ``` ## Forwarding to a central server The recommended hybrid: the embedded queue on the gateway is the offline buffer, and `queue.forward()` drains it to a central bunqueue server whenever the uplink is healthy. ```typescript const local = new Queue('telemetry', { embedded: true, dataPath: './edge.db' }); const forwarder = local.forward({ to: { host: 'queue.example.com', port: 6789, tls: true, token: Bun.env.BQ_TOKEN }, queue: 'telemetry-ingest', // optional remote queue name (default: same as local) concurrency: 4, // parallel forwards (default: 4) }); forwarder.on('forwarded', ({ id, remoteId }) => console.log(`${id} -> ${remoteId}`)); forwarder.on('error', (err) => console.error('uplink:', err.message)); // later: await forwarder.close(); ``` Central server, with native TLS (encrypted connections without a reverse proxy): ```bash bunqueue start \ --tls-cert /etc/bunqueue/cert.pem \ --tls-key /etc/bunqueue/key.pem \ --auth-tokens "$TOKEN" \ --data-path /var/lib/bunqueue/queue.db ``` What `forward()` guarantees: - **An uplink outage does not discard locally persisted work.** If the remote push fails, the job fails locally, retries with backoff, and after its attempts lands in the local DLQ. When the uplink returns, `local.retryDlq()` re-enqueues it. This assumes the gateway process or persistent volume survives; use `durable: true` for local jobs that cannot tolerate SQLite's 10ms hard-crash window. - **Re-forwards deduplicate while ownership is retained.** Each forwarded job carries a deterministic remote job id, `fwd::`, and the server treats that custom id as a no-op while its ownership record is retained. End-to-end processing remains at-least-once; see the bounded-window caveat below. - **Priority is preserved.** Pass `durable: true` in the forward options to bypass a remote SQLite server's write buffer. PostgreSQL admissions are already transactional. TLS certificate setup and client options (custom CA, self-signed) are in the [Native TLS guide](/guide/tls/). ## Offline buffering and durability The embedded queue persists to SQLite in WAL mode: committed frames use an append-only sidecar and SQLite recovery preserves transactional consistency across ordinary process or power interruption. Hardware, filesystem, and volume durability still belong to the operator. By default writes are batched for up to 10 ms; for readings you cannot afford to lose inside that window, mark them durable, meaning committed before `add()` returns: ```typescript await queue.add('critical-alarm', data, { durable: true }); ``` The published native results measure buffered bulk ingestion and sequential durable adds as different workloads, so do not turn them into one speed ratio. Both exceed typical sensor rates; use [the benchmark distributions](/guide/benchmarks/) for capacity planning on your hardware. ## Downsampling on the gateway Aggregate locally before forwarding, for a cheaper uplink and less central load. This schedules a recurring job every 5 minutes: ```typescript await queue.upsertJobScheduler( 'aggregate-5m', { every: 5 * 60 * 1000 }, { name: 'aggregate', data: { window: '5m' }, } ); ``` See [Cron & Scheduled Jobs](/guide/cron/) for cron expressions and timezones. ## Hardware notes - **Runtime**: Bun runs on Linux/macOS ARM64 and x64. Raspberry Pi 4/5 with a 64-bit OS works. 32-bit boards (Pi Zero/2, ESP-class hardware) do not run Bun; those devices publish MQTT to the gateway instead. - **Disk**: the SQLite file is the main consideration. Bound it with `removeOnComplete` (drop completed jobs), DLQ `maxAge`/`maxEntries`, and periodic `queue.clean(graceMs, limit)`. - **Backups**: on gateways with object storage access, enable [S3 backup](/guide/backup/), or ship the SQLite file with your own sync. ## Gotchas - **Forward dedup has a window.** The server remembers custom job ids in a bounded cache, and `removeOnComplete` on the remote evicts entries. A re-forward long after the original completed and was evicted can be accepted again. Retaining completed jobs extends the window; strict exactly-once effects require downstream idempotency. - **`forwarder.on('error')` is observability, not control flow.** Failed forwards are already handled by the local retry and DLQ path; the event just tells you the uplink is unhappy. - **One process per SQLite file.** Run the bridge, worker, and forwarder in the same process (as above), or switch to a local bunqueue server if you need several processes on the gateway. ## See also - [Native TLS](/guide/tls/), certificate setup and client options - [`examples/mqtt-bridge/`](https://github.com/egeominotti/bunqueue/tree/main/examples/mqtt-bridge), the runnable bridge - [Stall Detection](/guide/stall-detection/) and [DLQ](/guide/dlq/), what happens to stuck or poison readings --- # Replace BullMQ: Migrate to bunqueue in Minutes Replace BullMQ with bunqueue in minutes: keep your Queue and Worker API, delete Redis, and choose Bun-native SQLite or PostgreSQL multi-broker storage. URL: https://bunqueue.dev/guide/migration/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · migration

BullMQ code, minus Redis.

bunqueue keeps the BullMQ Queue and Worker API. Most migrations are an import change and a deleted Redis config. This page shows the diff first, then lists every real difference.

This page is for BullMQ users. It shows the full before/after up front, then walks the steps and the few API differences that actually matter. ## Before upgrading bunqueue 2.8 to 2.9 bunqueue 2.9 replaces Croner with Bun 1.4's native cron parser. Standard five-field schedules, the documented leading-seconds six-field form, and the published shortcuts continue to work. Croner-only extensions such as `L`, `W`, `#`, `+`, `?`, and seven-field years do not. Before stopping a 2.8 broker, inventory persisted schedules: ```bash bunqueue cron list bunqueue cron delete legacy-last-day # Recreate it with a supported schedule through your normal cron add/API path. ``` Do this while 2.8 is still running for both SQLite and PostgreSQL deployments. On startup, 2.9 validates the complete persisted collection before advancing any missed schedule. If one unsupported definition remains, startup fails with its escaped name and schedule plus remediation guidance; this prevents a silent omission or a due-timer retry loop. Do not hand-edit PostgreSQL cron payloads: replace or delete them through the 2.8 public API. Interval definitions are also checked before any scheduler, deduplication, or database mutation. `repeatEvery` must be a positive safe integer in milliseconds. Invalid legacy or corrupt rows fail startup with the cron name and remediation guidance. When both a valid calendar schedule and interval are present, the calendar schedule continues to take precedence for backward compatibility. ## The whole migration, one diff ```typescript // Before: BullMQ + Redis import { Queue, Worker } from 'bullmq'; const connection = { host: 'localhost', port: 6379 }; const queue = new Queue('emails', { connection }); const worker = new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { connection, concurrency: 5 } ); // After: bunqueue, no Redis import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('emails', { embedded: true, dataPath: './data/bunq.db' }); const worker = new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: true, concurrency: 5 } ); ``` ```typescript // Before: BullMQ + Redis import { Queue, Worker } from 'bullmq'; const connection = { host: 'localhost', port: 6379 }; const queue = new Queue('emails', { connection }); const worker = new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { connection, concurrency: 5 } ); // After: bunqueue, no Redis import { Queue, Worker } from 'bunqueue-client'; const queue = new Queue('emails', { embedded: false }); const worker = new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: false, concurrency: 5 } ); ``` BullMQ is a Node.js library, so the before/after pairs on this page are JavaScript and TypeScript only. If the destination is Python, the target-side API is in the [Python SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/python). BullMQ is a Node.js library, so the before/after pairs on this page are JavaScript and TypeScript only. If the destination is PHP, the target-side API is in the [PHP SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/php). BullMQ is a Node.js library, so the before/after pairs on this page are JavaScript and TypeScript only. If the destination is Go, the target-side API is in the [Go SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/go). BullMQ is a Node.js library, so the before/after pairs on this page are JavaScript and TypeScript only. If the destination is Rust, the target-side API is in the [Rust SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/rust). BullMQ is a Node.js library, so the before/after pairs on this page are JavaScript and TypeScript only. If the destination is Elixir, the target-side API is in the [Elixir SDK reference](https://github.com/egeominotti/bunqueue/tree/main/sdk/elixir). _Embedded mode (`embedded: true`) requires the Bun runtime. On Node.js and Deno, `bunqueue-client` connects to a bunqueue server with `embedded: false`; the Queue and Worker implementation and API are shared with `bunqueue/client`._ `embedded: true` runs the queue inside your process, backed by a local SQLite file, so there is no queue server to operate. `dataPath` says where that file lives. :::caution[Set a data path] Without `dataPath` (or the `BUNQUEUE_DATA_PATH` env var), embedded mode keeps jobs in memory only and loses them on restart. Always set one in production. ::: If your producers and workers run in separate processes or machines, start a bunqueue server (`bunqueue start`) and replace `embedded: true` with `connection: { host, port }`. The rest of the code is identical. See [Server Mode](/guide/server/). ## Step by step ```bash bun remove bullmq ioredis bun add bunqueue ``` Then, in your code: 1. Change imports from `'bullmq'` to `'bunqueue/client'`. `Queue`, `Worker`, `QueueEvents`, and `FlowProducer` all exist under the same names; Pro-oriented code may use the `QueuePro`, `WorkerPro`, and `QueueEventsPro` aliases plus the `JobPro` type. 2. Delete the Redis connection object. Pass `embedded: true` plus a `dataPath`, or `connection: { host, port }` for server mode. 3. Run your test suite. Job options, events, and flows behave the same, the differences are listed below. 4. Remove the Redis server from your infrastructure. ## What stays the same - **Classes**: `Queue`, `Worker`, `QueueEvents`, `FlowProducer`, same constructors minus the Redis connection. - **Job options**: `priority`, `delay`, `attempts`, `backoff` (including the `{ type, delay }` object form), `jobId`, `removeOnComplete`, `removeOnFail`, `repeat`. - **`jobId` deduplication**: adding the same `jobId` twice returns the existing job instead of creating a duplicate, exactly like BullMQ. - **Events**: `worker.on('completed' | 'failed' | 'progress', ...)` fire with the same signatures. - **Job states**: the full BullMQ v5 state machine, including `prioritized` and `waiting-children`. - **Flows**: `FlowProducer.add()`, `addBulk()`, `getFlow()`, `queuesOptions`, `failParentOnFailure`, `removeDependencyOnFailure`. The Bun package sends one broker-side `PUSHF` graph. Configured SQLite commits every row before publishing local state, while PostgreSQL commits the complete graph in one database transaction; both provide all-or-nothing creation without client-side rollback. - **Per-worker rate limiting**: `limiter: { max, duration }` on `WorkerOptions`, unchanged. ### BullMQ Pro-oriented APIs in the Bun package The Pro aliases point directly at bunqueue's native implementations: ```typescript import { QueuePro, WorkerPro, QueueEventsPro } from 'bunqueue/client'; import type { JobPro } from 'bunqueue/client'; ``` No extra package, connection, or license is required. The Bun client supports: - job groups with round-robin fairness, priority/FIFO lanes, atomic `maxSize`, pause/resume, backlog and priority queries, per-group concurrency/rate defaults, local overrides, and manual `worker.rateLimitGroup()` cooldowns; - native processor batches through `batch: { size, minSize?, timeout?, groupAffinity? }`, `job.getBatch()`, and selective `member.setAsFailed(error)`; - cooperative active-job cancellation through the processor's second-argument `AbortSignal`; and - structural Observable processor results without requiring RxJS. These features use the same public contract in embedded memory/SQLite and TCP server mode. PostgreSQL 15–18 makes group capacity, ordering, pause, limits, and manual deadlines authoritative across brokers. BullMQ Pro telemetry and its NestJS integration are intentionally outside this compatibility layer; use bunqueue's existing metrics/events and framework-neutral Worker lifecycle instead. Ordinary BullMQ job code remains unchanged: ```typescript // This BullMQ code runs as-is on bunqueue await queue.add('task', data, { priority: 1, delay: 5000, attempts: 3, backoff: { type: 'exponential', delay: 1000 }, removeOnComplete: true, jobId: 'order-123', }); ``` ## What changes ### Backoff Backoff is the wait time between retries. Both BullMQ forms work, plus a shorthand: ```typescript backoff: { type: 'exponential', delay: 1000 } // same as BullMQ backoff: { type: 'fixed', delay: 5000 } // same as BullMQ backoff: 1000 // shorthand: exponential with 1000ms base ``` Exponential retries wait roughly 2s, 4s, 8s with a 1000ms base (`delay * 2^attempts`). bunqueue adds automatic jitter (a small random spread) so thousands of failed jobs do not retry at the same instant, and caps delays at 1 hour by default. ### Repeatable jobs BullMQ's legacy `repeat: { cron: '...' }` key becomes `pattern`: ```typescript await queue.add('task', data, { repeat: { pattern: '0 * * * *' } }); // cron syntax await queue.add('task', data, { repeat: { every: 3600000 } }); // fixed interval ``` ### removeOnComplete is boolean only BullMQ accepts `removeOnComplete: { age, count }` for retention rules. bunqueue accepts only `true` or `false`. Use `queue.clean()` for age-based cleanup. ### Sandboxed processors BullMQ lets you pass a file path as the processor to run it in a child process. In bunqueue, move that logic into an inline processor function (recommended, production-ready): ```typescript const worker = new Worker( 'queue', async (job) => { // the same logic from your processor.js return result; }, { embedded: true, concurrency: 4 } ); ``` An experimental `SandboxedWorker` (built on Bun Workers) exists if you need process isolation. See [Worker vs SandboxedWorker](/guide/worker/sandboxed/#worker-vs-sandboxedworker). ### Queue-level rate limiting The per-worker `limiter` works as in BullMQ. bunqueue also offers a queue-level limit: ```typescript queue.setGlobalRateLimit(100); // max 100 jobs per second across all workers queue.setGlobalRateLimit(100, 60_000); // max 100 jobs per minute ``` The optional second `duration` argument is the window in milliseconds and is enforced by the broker in both embedded and TCP modes. It defaults to 1,000 ms. ### No Redis required bunqueue uses in-memory storage when no persistence backend is configured. A single broker can opt into SQLite persistence while many workers connect over TCP. If the broker tier must scale, standalone servers can instead share a PostgreSQL 15–18 database/namespace (18.6 recommended). This is not Redis Cluster and does not copy its topology or Redis-specific semantics; see [storage backends](/guide/databases/) and [when BullMQ is the better pick](/guide/comparison/#when-to-use-bullmq-instead). ## Migration checklist - [ ] `bun remove bullmq ioredis`, `bun add bunqueue` - [ ] Imports point to `bunqueue/client` - [ ] Redis connection config deleted, `embedded: true` + `dataPath` (or `connection`) added - [ ] For multi-broker server mode, PostgreSQL 15–18 URL/namespace and unique broker IDs configured - [ ] `repeat: { cron }` renamed to `repeat: { pattern }` - [ ] `removeOnComplete`/`removeOnFail` objects replaced with booleans - [ ] File-path processors converted to inline processor functions - [ ] Tests pass - [ ] Redis removed from infrastructure ## Gotchas - **Persistence is opt-in for embedded mode.** No `dataPath` means in-memory only. Server mode reads `BUNQUEUE_DATA_PATH` or `--data-path`. - **One writer per SQLite file.** Do not point two embedded processes or brokers at the same file. Multiple clients use one SQLite server; multiple active brokers use PostgreSQL 15–18; 18.6 is recommended. - **`SandboxedWorker` is experimental.** Prefer inline processors in production. ## Getting help Open a [GitHub issue](https://github.com/egeominotti/bunqueue/issues) or ask in [Discussions](https://github.com/egeominotti/bunqueue/discussions). :::tip[Related] - [bunqueue vs BullMQ](/guide/comparison/), features and benchmarks - [Queue API](/guide/queue/) and [Worker API](/guide/worker/) - [FAQ](/faq/), common migration questions ::: --- # bunqueue Code Examples: Copy-Paste Recipes for Bun Short, working bunqueue examples: retries, scheduled jobs, deduplication, server mode, events, graceful shutdown, and workflows. URL: https://bunqueue.dev/examples/ import { Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components'; import ExamplesLearningPath from '../../components/examples/ExamplesLearningPath.astro'; import JobJourney from '../../components/examples/JobJourney.astro'; import TopologyExplorer from '../../components/examples/TopologyExplorer.astro';
reference · examples

Code examples you copy and ship.

Short recipes for the tasks you hit first: retries, schedules, dedup, events, shutdown and workflows. Each one links to the guide that covers it in depth.

This page starts with one local job, adds reliability and operational controls, then finishes with workflows and a tested PostgreSQL multi-broker deployment. For domain scenarios such as email, webhooks, and payments, see [use cases](/guide/use-cases/). ## Learning path Follow the stages in order on a first read. Each stage links directly to the relevant recipe, so you can return later and use the page as a reference. :::note[Persistence] The Bun tabs use embedded mode, meaning the queue runs inside your process with no separate server. Pass `dataPath` so jobs are saved to a SQLite file, otherwise everything is in-memory and lost on restart: ```typescript const queue = new Queue('tasks', { embedded: true, dataPath: './data/bunq.db' }); ``` The other languages connect to a bunqueue server (default `localhost:6789`), where persistence is configured server-side with `--data-path`. ::: ## Minimal queue and worker The smallest complete setup: add a job, process it in the background. ```typescript import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('tasks', { embedded: true, dataPath: './data/bunq.db' }); const worker = new Worker( 'tasks', async (job) => { console.log('processing', job.data); return { done: true }; }, { embedded: true, concurrency: 5 } ); await queue.add('hello', { message: 'world' }); ``` ```typescript import { Queue, Worker } from 'bunqueue-client'; const queue = new Queue('tasks'); // connects to localhost:6789 const worker = new Worker( 'tasks', async (job) => { console.log('processing', job.data); return { done: true }; }, { concurrency: 5 } ); await queue.add('hello', { message: 'world' }); ``` ```python from bunqueue import Queue, Worker queue = Queue("tasks") # connects to localhost:6789 def process(job): print("processing", job.data) return {"done": True} worker = Worker("tasks", process, concurrency=5) queue.add("hello", {"message": "world"}) ``` ```php use Bunqueue\Queue; use Bunqueue\Worker; $queue = new Queue('tasks'); // connects to localhost:6789 $queue->add('hello', ['message' => 'world']); $worker = new Worker('tasks', function (Bunqueue\Job $job) { print_r($job->data()); return ['done' => true]; }); $worker->run(); // blocking loop ``` ```go queue := bunqueue.NewQueue("tasks", bunqueue.Options{}) // localhost:6789 defer queue.Close() queue.Add("hello", map[string]any{"message": "world"}, nil) worker := bunqueue.NewWorker("tasks", func(job *bunqueue.Job) (any, error) { fmt.Println("processing", job.Data()) return map[string]any{"done": true}, nil }, bunqueue.WorkerOptions{Concurrency: 5}) worker.Run() // blocking pull loop ``` ```rust use bunqueue_client::{ConnectionOptions, JobOptions, Queue, Value, Worker, WorkerOptions}; let queue = Queue::new("tasks", ConnectionOptions::default()); // localhost:6789 let data = Value::Map(vec![(Value::from("message"), Value::from("world"))]); queue.add("hello", data, JobOptions::default())?; let worker = Worker::new( "tasks", |job| { println!("processing {:?}", job.data()); Ok(Value::from(true)) }, WorkerOptions { concurrency: 5, ..Default::default() }, ); worker.run()?; ``` ```elixir queue = Bunqueue.queue("tasks") # connects to localhost:6789 {:ok, _job} = Bunqueue.Queue.add(queue, "hello", %{message: "world"}) worker = Bunqueue.Worker.new("tasks", fn job -> IO.inspect(job.data, label: "processing") {:ok, %{done: true}} end, concurrency: 5) Bunqueue.Worker.run(worker) ``` More in the [quickstart](/guide/quickstart/). ## Understand the job lifecycle Every job starts with a producer, waits until it is eligible, and is claimed by one worker. A successful acknowledgement completes it. A failure either schedules another attempt after backoff or moves the job to the dead letter queue when no attempt remains. Use the controls to compare the success, retry, and terminal-failure routes one transition at a time. The same state rules apply in embedded, SQLite, and PostgreSQL deployments. ## Retries and the dead letter queue A thrown error retries the job with backoff, a growing delay between attempts. Jobs that run out of attempts land in the dead letter queue (DLQ), a holding area you can inspect and retry. ```typescript await queue.add( 'flaky-call', { url: 'https://api.example.com' }, { attempts: 5, // try up to 5 times backoff: 2000, // wait 2s, 4s, 8s... between tries } ); // After all attempts fail: const failed = queue.getDlq(); // inspect what died and why queue.retryDlq(); // send everything back for another run ``` ```typescript await queue.add( 'flaky-call', { url: 'https://api.example.com' }, { attempts: 5, // try up to 5 times backoff: 2000, // wait 2s, 4s, 8s... between tries } ); // After all attempts fail: const failed = await queue.getDlq(); // inspect what died and why await queue.retryDlq(); // send everything back for another run ``` ```python queue.add("flaky-call", {"url": "https://api.example.com"}, attempts=5, # try up to 5 times backoff=2000) # wait 2s, 4s, 8s... between tries # After all attempts fail: failed = queue.get_dlq() # inspect what died and why queue.retry_dlq() # send everything back for another run ``` ```php $queue->add('flaky-call', ['url' => 'https://api.example.com'], [ 'attempts' => 5, // try up to 5 times 'backoff' => 2000, // wait 2s, 4s, 8s... between tries ]); // After all attempts fail: $failed = $queue->getDlq(); // inspect what died and why $queue->retryDlq(); // send everything back for another run ``` ```go queue.Add("flaky-call", map[string]any{"url": "https://api.example.com"}, bunqueue.JobOptions{ "attempts": 5, // try up to 5 times "backoff": 2000, // wait 2s, 4s, 8s... between tries }) // After all attempts fail: failed, _ := queue.GetDlq(0) // inspect what died and why (0 = server default count) queue.RetryDlq("", 0) // send everything back for another run ``` ```rust use bunqueue_client::{Backoff, JobOptions}; queue.add("flaky-call", data, JobOptions { attempts: Some(5), // try up to 5 times backoff: Some(Backoff::Milliseconds(2000)), // wait 2s, 4s, 8s... between tries ..Default::default() })?; // After all attempts fail: let failed = queue.get_dlq(None)?; // inspect what died and why queue.retry_dlq(None, None)?; // send everything back for another run ``` ```elixir {:ok, _job} = Bunqueue.Queue.add(queue, "flaky-call", %{url: "https://api.example.com"}, attempts: 5, # try up to 5 times backoff: 2000 # wait 2s, 4s, 8s... between tries ) # After all attempts fail: {:ok, failed} = Bunqueue.Queue.dlq(queue) # inspect what died and why {:ok, _count} = Bunqueue.Queue.retry_dlq(queue) # send everything back for another run ``` Details and auto-retry config in the [DLQ guide](/guide/dlq/). ## Scheduled and repeating jobs Attach a `repeat` option, or use `upsertJobScheduler()` for named schedules. Both persist in the selected durable backend and survive restarts; PostgreSQL mode coordinates named schedules across brokers. ```typescript // Cron expression: every day at 6 AM await queue.add( 'daily-report', { type: 'sales' }, { repeat: { pattern: '0 6 * * *' }, } ); // Plain interval: every 30 minutes await queue.add( 'health-check', {}, { repeat: { every: 1_800_000 }, } ); // Named, updatable schedule await queue.upsertJobScheduler( 'cleanup', { pattern: '0 3 * * *' }, { data: { olderThanDays: 30 }, } ); ``` ```typescript // Cron expression: every day at 6 AM await queue.add( 'daily-report', { type: 'sales' }, { repeat: { pattern: '0 6 * * *' }, } ); // Plain interval: every 30 minutes await queue.add( 'health-check', {}, { repeat: { every: 1_800_000 }, } ); // Named, updatable schedule await queue.upsertJobScheduler( 'cleanup', { pattern: '0 3 * * *' }, { data: { olderThanDays: 30 }, } ); ``` ```python # Cron expression: every day at 6 AM queue.add("daily-report", {"type": "sales"}, repeat={"pattern": "0 6 * * *"}) # Plain interval: every 30 minutes queue.add("health-check", {}, repeat={"every": 1_800_000}) # Named, updatable schedule queue.upsert_job_scheduler("cleanup", {"pattern": "0 3 * * *"}, {"data": {"olderThanDays": 30}}) ``` ```php // Cron expression: every day at 6 AM $queue->add('daily-report', ['type' => 'sales'], ['repeat' => ['pattern' => '0 6 * * *']]); // Plain interval: every 30 minutes $queue->add('health-check', [], ['repeat' => ['every' => 1800000]]); // Named, updatable schedule $queue->upsertJobScheduler('cleanup', ['pattern' => '0 3 * * *'], ['data' => ['olderThanDays' => 30]], ); ``` ```go // Cron expression: every day at 6 AM queue.Add("daily-report", map[string]any{"type": "sales"}, bunqueue.JobOptions{"repeat": map[string]any{"pattern": "0 6 * * *"}}) // Plain interval: every 30 minutes queue.Add("health-check", nil, bunqueue.JobOptions{"repeat": map[string]any{"every": 1800000}}) // Named, updatable schedule queue.UpsertJobScheduler("cleanup", bunqueue.SchedulerRepeat{Pattern: "0 3 * * *"}, bunqueue.SchedulerTemplate{Data: map[string]any{"olderThanDays": 30}}, ) ``` ```rust use bunqueue_client::{JobOptions, SchedulerRepeat, SchedulerTemplate, Value}; // Cron expression: every day at 6 AM let repeat = Value::Map(vec![(Value::from("pattern"), Value::from("0 6 * * *"))]); queue.add("daily-report", data, JobOptions { repeat: Some(repeat), ..Default::default() })?; // Plain interval: every 30 minutes let repeat = Value::Map(vec![(Value::from("every"), Value::from(1_800_000))]); queue.add("health-check", Value::Nil, JobOptions { repeat: Some(repeat), ..Default::default() })?; // Named, updatable schedule queue.upsert_job_scheduler( "cleanup", SchedulerRepeat { pattern: Some("0 3 * * *".into()), ..Default::default() }, SchedulerTemplate { data: Value::Map(vec![(Value::from("olderThanDays"), Value::from(30))]), ..Default::default() }, )?; ``` ```elixir # Cron expression: every day at 6 AM {:ok, _} = Bunqueue.Queue.add(queue, "daily-report", %{type: "sales"}, repeat: %{pattern: "0 6 * * *"} ) # Plain interval: every 30 minutes {:ok, _} = Bunqueue.Queue.add(queue, "health-check", %{}, repeat: %{every: 1_800_000}) # Named, updatable schedule :ok = Bunqueue.Queue.upsert_scheduler(queue, "cleanup", %{pattern: "0 3 * * *"}, %{data: %{olderThanDays: 30}} ) ``` Timezones and schedule management in the [cron guide](/guide/cron/). ## Deduplicate jobs with jobId Adding a job with a `jobId` that already exists returns the existing job instead of creating a duplicate. Useful for "exactly one welcome email per user" and safe re-runs after a restart. ```typescript const a = await queue.add('notify', { userId: 'u1' }, { jobId: 'welcome-u1' }); const b = await queue.add('notify', { userId: 'u1' }, { jobId: 'welcome-u1' }); console.log(a.id === b.id); // true, same job ``` ```typescript const a = await queue.add('notify', { userId: 'u1' }, { jobId: 'welcome-u1' }); const b = await queue.add('notify', { userId: 'u1' }, { jobId: 'welcome-u1' }); console.log(a.id === b.id); // true, same job ``` ```python a = queue.add("notify", {"user_id": "u1"}, job_id="welcome-u1") b = queue.add("notify", {"user_id": "u1"}, job_id="welcome-u1") print(a.id == b.id) # True, same job ``` ```php $a = $queue->add('notify', ['userId' => 'u1'], ['jobId' => 'welcome-u1']); $b = $queue->add('notify', ['userId' => 'u1'], ['jobId' => 'welcome-u1']); var_dump($a->id() === $b->id()); // true, same job ``` ```go a, _ := queue.Add("notify", map[string]any{"userId": "u1"}, bunqueue.JobOptions{"jobId": "welcome-u1"}) b, _ := queue.Add("notify", map[string]any{"userId": "u1"}, bunqueue.JobOptions{"jobId": "welcome-u1"}) fmt.Println(a.ID() == b.ID()) // true, same job ``` ```rust let opts = || JobOptions { job_id: Some("welcome-u1".into()), ..Default::default() }; let a = queue.add("notify", data.clone(), opts())?; let b = queue.add("notify", data, opts())?; assert_eq!(a.id(), b.id()); // same job ``` ```elixir {:ok, a} = Bunqueue.Queue.add(queue, "notify", %{user_id: "u1"}, jobId: "welcome-u1") {:ok, b} = Bunqueue.Queue.add(queue, "notify", %{user_id: "u1"}, jobId: "welcome-u1") a.id == b.id # true, same job ``` ## Choose a deployment topology Start embedded while one process is the right boundary. Introduce a TCP broker when producers and workers need separate processes or different languages. Add PostgreSQL and multiple active brokers only when broker failover, horizontal scale, or shared cross-host limits justify the extra moving parts. ## Distributed mode (server + TCP) Run one bunqueue server, connect producers and workers from any number of processes or machines, in any language. ```bash bunqueue start --tcp-port 6789 --data-path ./data/tasks.db ``` ```typescript // producer.ts import { Queue } from 'bunqueue/client'; const queue = new Queue('tasks', { connection: { host: 'localhost', port: 6789 } }); await queue.addBulk(items.map((i) => ({ name: 'process', data: i }))); // worker.ts (run as many copies as you want) import { Worker } from 'bunqueue/client'; new Worker( 'tasks', async (job) => { return { processed: job.data.id }; }, { connection: { host: 'localhost', port: 6789 }, concurrency: 50 } ); ``` ```typescript // producer.ts import { Queue } from 'bunqueue-client'; const queue = new Queue('tasks', { host: 'localhost', port: 6789 }); await queue.addBulk(items.map((i) => ({ name: 'process', data: i }))); // worker.ts (run as many copies as you want) import { Worker } from 'bunqueue-client'; new Worker( 'tasks', async (job) => { return { processed: job.data.id }; }, { concurrency: 50 } ); ``` ```python # producer.py from bunqueue import Queue queue = Queue("tasks", host="localhost", port=6789) queue.add_bulk([{"name": "process", "data": i} for i in items]) # worker.py (run as many copies as you want) from bunqueue import Worker Worker("tasks", lambda job: {"processed": job.data["id"]}, concurrency=50).run() ``` ```php // producer.php $queue = new Bunqueue\Queue('tasks', ['host' => 'localhost', 'port' => 6789]); $queue->addBulk(array_map(fn ($i) => ['name' => 'process', 'data' => $i], $items)); // worker.php (run as many copies as you want) $worker = new Bunqueue\Worker('tasks', fn (Bunqueue\Job $job) => ['processed' => $job->data()['id']]); $worker->run(); ``` ```go // producer queue := bunqueue.NewQueue("tasks", bunqueue.Options{Host: "localhost", Port: 6789}) entries := make([]bunqueue.BulkEntry, 0, len(items)) for _, item := range items { entries = append(entries, bunqueue.BulkEntry{Name: "process", Data: item}) } queue.AddBulk(entries) // worker (run as many copies as you want) worker := bunqueue.NewWorker("tasks", func(job *bunqueue.Job) (any, error) { return map[string]any{"processed": job.Data()["id"]}, nil }, bunqueue.WorkerOptions{Concurrency: 50}) worker.Run() ``` ```rust use bunqueue_client::{BulkEntry, ConnectionOptions, JobOptions, Queue, Worker, WorkerOptions}; // producer let queue = Queue::new("tasks", ConnectionOptions::default()); let entries = items .into_iter() .map(|data| BulkEntry { name: "process".into(), data, options: JobOptions::default() }) .collect::>(); let ids = queue.add_bulk(entries)?; // worker (run as many copies as you want) let worker = Worker::new("tasks", |job| process(job), WorkerOptions { concurrency: 50, ..Default::default() }); worker.run()?; ``` ```elixir # producer queue = Bunqueue.queue("tasks", host: "localhost", port: 6789) {:ok, _ids} = Bunqueue.Queue.add_bulk(queue, Enum.map(items, &%{name: "process", data: &1})) # worker (run as many copies as you want) worker = Bunqueue.Worker.new("tasks", fn job -> {:ok, %{processed: job.data["id"]}} end, concurrency: 50) Bunqueue.Worker.run(worker) ``` Server setup, auth and TLS in the [server guide](/guide/server/). ## Watch job events `QueueEvents` streams lifecycle events for a queue, and workers emit their own events. ```typescript import { QueueEvents } from 'bunqueue/client'; const events = new QueueEvents('tasks', { connection: { host: '127.0.0.1', port: 6789 }, }); await events.waitUntilReady(); events.on('completed', ({ jobId, returnvalue }) => console.log('done', jobId, returnvalue)); events.on('failed', ({ jobId, failedReason }) => console.error('failed', jobId, failedReason)); events.on('progress', ({ jobId, data }) => console.log('progress', jobId, data)); worker.on('completed', (job, result) => console.log('worker finished', job.id)); worker.on('failed', (job, error) => console.error('worker error', error.message)); ``` ```typescript // Worker-side events (QueueEvents streaming is a Bun bunqueue feature) worker.on('completed', (job, result) => console.log('worker finished', job.id)); worker.on('failed', (job, error) => console.error('worker error', error.message)); worker.on('error', (err) => console.error(err)); // always attach ``` ```python worker.on("completed", lambda job, result: print("worker finished", job.id)) worker.on("failed", lambda job, err: print("worker error", job.id, err)) worker.on("progress", lambda job, progress: print("progress", job.id, progress)) ``` ```php $worker->on('completed', fn ($job, $result) => print("worker finished {$job->id()}\n")); $worker->on('failed', fn ($job, $err) => print("worker error {$job->id()}\n")); $worker->on('error', fn ($err) => print($err->getMessage() . "\n")); ``` ```go worker.On("completed", func(args ...any) { job := args[0].(*bunqueue.Job) log.Printf("worker finished %s", job.ID()) }) worker.On("error", func(args ...any) { log.Println(args[0]) }) ``` ```rust // Rust has no worker event emitter. Per-job outcomes are the processor's return // value; transport lifecycle arrives on the connection telemetry callback. let options = ConnectionOptions { telemetry: Some(Arc::new(|event| println!("{event:?}"))), ..Default::default() }; ``` ```elixir # Elixir has no worker event emitter. Per-job outcomes are the handler's return # value; transport lifecycle arrives on the connection `:event_handler` callback. queue = Bunqueue.queue("emails", event_handler: &IO.inspect/1) ``` _`QueueEvents` streaming is available in the Bun `bunqueue` package only; see the [SDK guide](/guide/sdks/#worker-events)._ Dashboards, metrics and Prometheus in the [monitoring guide](/guide/monitoring/). ## Graceful shutdown On SIGTERM, stop pulling new jobs, let active ones finish, then close. ```typescript async function shutdown() { worker.pause(); // stop accepting new jobs await worker.close(); // wait for active jobs (worker.close(true) forces a stop) await queue.close(); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); ``` ```typescript async function shutdown() { await worker.close(); // stop pulling, flush batched ACKs, drain in-flight jobs queue.close(); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); ``` ```python try: worker.run() except KeyboardInterrupt: worker.close() # wait for in-flight jobs to drain queue.close() ``` ```php $worker->installSignalHandlers(); // SIGTERM / SIGINT -> graceful stop $worker->run(); // returns after the in-flight job finishes $worker->close(); // unregister and close the connection ``` ```go go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig worker.Stop() // stop pulling; in-flight jobs finish }() worker.Run() worker.Close() // unregister and close the connection ``` ```rust // From a signal handler or another thread: worker.stop(); // ask the pull loop to exit; run() returns after draining worker.close(); // unregister and close the connection queue.close(); ``` ```elixir Bunqueue.Worker.stop(worker) # drain, unregister and close Bunqueue.Queue.close(queue) ``` The full production pattern, including timeouts and the embedded manager, is in the [production guide](/guide/production/). ## Workflow: automatic rollback on failure The workflow engine runs multi-step processes where each step can declare a `compensate` function, code that undoes the step if a later one fails. This is the saga pattern: charge succeeded but shipping failed, so the charge is refunded automatically. _The workflow engine ships with the Bun `bunqueue` package (`bunqueue/workflow`) and runs embedded. From the other SDKs, use [flows](/guide/flow/) for multi-step orchestration against the server._ ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const orderFlow = new Workflow('order') .step( 'reserve-stock', async (ctx) => { await inventory.reserve((ctx.input as { orderId: string }).orderId); return { reserved: true }; }, { compensate: async () => { await inventory.release(); }, // runs if a later step fails } ) .step( 'charge', async (ctx) => { const txId = await stripe.charge((ctx.input as { amount: number }).amount); return { txId }; }, { compensate: async () => { await stripe.refund(); }, } ) .step('confirm', async (ctx) => { const { txId } = ctx.steps['charge'] as { txId: string }; await mailer.send('order-confirm', { txId }); return { done: true }; }); const engine = new Engine({ embedded: true }); engine.register(orderFlow); await engine.start('order', { orderId: 'ORD-1', amount: 99.99 }); ``` ## Workflow: wait for a human decision `waitFor()` pauses the workflow until someone calls `engine.signal()`, hours or days later. ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const expenseFlow = new Workflow('expense') .step('submit', async (ctx) => { await slack.notify('#approvals', `New expense: ${JSON.stringify(ctx.input)}`); return { submitted: true }; }) .waitFor('manager-decision') .step('process', async (ctx) => { const decision = ctx.signals['manager-decision'] as { approved: boolean }; return { status: decision.approved ? 'paid' : 'rejected' }; }); const engine = new Engine({ embedded: true }); engine.register(expenseFlow); const run = await engine.start('expense', { amount: 500 }); // Later, when the manager clicks approve: await engine.signal(run.id, 'manager-decision', { approved: true }); ``` Branching, parallel steps, loops, sub-workflows and schema validation are all in the [workflow guide](/guide/workflow/). ## End-to-end example projects The complete project below combines the earlier concepts. Read it after the single-broker examples if this is your first bunqueue deployment. Run PostgreSQL 18.6, three active brokers, multiple queues and workers, authenticated metrics, custom-ID idempotency, retries, DLQ recovery, shared limits, events, and durable flows. Every source is executed in disposable containers and has a published [engineering report](/examples/postgres-multibroker/validation/). [Open the complete example →](/examples/postgres-multibroker/) :::tip[Where next] - [Use cases](/guide/use-cases/), end-to-end patterns for emails, webhooks, images and payments - [Queue guide](/guide/queue/), every job option explained - [Worker guide](/guide/worker/), concurrency, heartbeats and batching - [Migration from BullMQ](/guide/migration/), the API is intentionally close ::: --- # PostgreSQL Multi-Broker Examples A tested, end-to-end bunqueue example: PostgreSQL 18.6, three active brokers, multiple queues and workers, retries, DLQ, limits, events, and durable flows. URL: https://bunqueue.dev/examples/postgres-multibroker/ import { Aside, Card, CardGrid } from '@astrojs/starlight/components';
examples · postgresql · multi-broker

Three brokers. One database. Real work.

Build a disposable PostgreSQL 18.6 cluster, run three active bunqueue brokers, and exercise the public SDK across queues, workers, events, retries, the DLQ, shared limits, and durable job graphs.

## Architecture ```text PostgreSQL 18.6 authoritative state / | \ broker-a broker-b broker-c producer TCP worker TCP observer TCP \ | / \------ public bunqueue SDK --/ multiple queues ``` All brokers use the same PostgreSQL URL and `BUNQUEUE_POSTGRES_NAMESPACE`. Each broker has a different, stable `BUNQUEUE_BROKER_ID`. Clients connect to a broker over TCP; they never connect directly to PostgreSQL. ## Follow the example [Build PostgreSQL and three brokers, understand every environment variable, and tear the whole project down.](/examples/postgres-multibroker/docker/) [Route producers, workers, and QueueEvents through different brokers while preserving one queue state.](/examples/postgres-multibroker/queues-workers/) [Exercise custom-ID idempotency, pause/resume, shared limits, retries, DLQ inspection, and operator retry.](/examples/postgres-multibroker/reliability/) [Run a three-level FlowProducer graph across three queues and verify dependency ordering and durable results.](/examples/postgres-multibroker/flows/) [Scale to N brokers safely, budget connections, route traffic, monitor readiness, secure the deployment, and coordinate upgrades.](/examples/postgres-multibroker/operations/) [Read the exact test matrix, measured functional timings, cleanup proof, findings, and honest exclusions.](/examples/postgres-multibroker/validation/) ## One-command verification From the repository root: ```bash ./examples/postgres-multibroker/verify.sh ``` The script creates a unique Compose project, runs each scenario separately with a 60-second deadline, and installs an exit trap before creating infrastructure. Success, failure, timeout, `SIGINT`, and `SIGTERM` all run ordered, independent resource and local-image removal attempts. Invalid project overrides are rejected before Docker is called. ## What “N brokers” means The included topology uses three brokers because it is large enough to prove cross-broker behavior without hiding identities behind a load balancer. The same invariants apply to any supported fleet size: | Setting | Across the fleet | | ---------------- | --------------------------------------------------------------- | | PostgreSQL URL | Same authoritative database | | Namespace | Same for shared queues; different to isolate environments | | Broker ID | Unique and stable per active broker process | | TCP/HTTP ports | May be identical inside separate containers | | PostgreSQL pool | Budget `brokers × poolSize`, plus operational headroom | | bunqueue version | Keep identical; mixed-version schema operation is not supported | Compose cannot safely scale one service definition when it contains one static broker ID. Declare instances explicitly, as this example does, or derive the ID from a stable orchestrator identity such as a Kubernetes Pod name. ## Scope This is a functional correctness example, not a benchmark. It proves real PostgreSQL persistence and multi-broker SDK behavior. It does not claim a production capacity number, test PostgreSQL primary failover, or replace the repository's deeper crash, lease-fencing, contention, and model campaigns. Next: [build the Docker topology](/examples/postgres-multibroker/docker/). --- # Docker: PostgreSQL and Three Brokers Run the verified Docker Compose topology: PostgreSQL 18.6, three uniquely identified bunqueue brokers, authenticated metrics, health checks, and automatic teardown. URL: https://bunqueue.dev/examples/postgres-multibroker/docker/ import { Aside, Code, Steps } from '@astrojs/starlight/components';
examples · docker topology

PostgreSQL plus three active brokers.

A local topology that behaves like a broker fleet: private east-west traffic, unique broker identities, readiness gates, loopback-only host ports, and no bind mounts.

## Complete Compose file This is the exact file used by the automated example gate. ```yaml title="examples/postgres-multibroker/compose.yaml" x-broker-environment: &broker-environment DATA_PATH: '' BUNQUEUE_STORAGE_DRIVER: postgres BUNQUEUE_POSTGRES_URL: postgres://bunqueue:local-demo-only@postgres:5432/bunqueue BUNQUEUE_POSTGRES_NAMESPACE: realistic-example BUNQUEUE_POSTGRES_POOL_SIZE: '4' BUNQUEUE_POSTGRES_LEASE_DURATION_MS: '10000' BUNQUEUE_POSTGRES_POLL_INTERVAL_MS: '50' AUTH_TOKENS: demo-token METRICS_AUTH: 'true' TCP_PORT: '6789' HTTP_PORT: '6790' SHUTDOWN_TIMEOUT_MS: '15000' x-broker: &broker build: context: ../.. dockerfile: Dockerfile depends_on: postgres: condition: service_healthy healthcheck: test: ['CMD-SHELL', 'wget --spider -q http://127.0.0.1:6790/ready'] interval: 2s timeout: 3s retries: 30 start_period: 5s restart: unless-stopped networks: [demo] services: postgres: image: postgres:18.6-alpine environment: POSTGRES_DB: bunqueue POSTGRES_USER: bunqueue POSTGRES_PASSWORD: local-demo-only healthcheck: test: ['CMD-SHELL', 'pg_isready -U bunqueue -d bunqueue'] interval: 2s timeout: 3s retries: 30 volumes: - postgres-data:/var/lib/postgresql restart: unless-stopped networks: [demo] broker-a: <<: *broker environment: <<: *broker-environment BUNQUEUE_BROKER_ID: broker-a ports: - '127.0.0.1:${BROKER_A_TCP_PORT:-16789}:6789' - '127.0.0.1:${BROKER_A_HTTP_PORT:-16790}:6790' broker-b: <<: *broker environment: <<: *broker-environment BUNQUEUE_BROKER_ID: broker-b ports: - '127.0.0.1:${BROKER_B_TCP_PORT:-17789}:6789' - '127.0.0.1:${BROKER_B_HTTP_PORT:-17790}:6790' broker-c: <<: *broker environment: <<: *broker-environment BUNQUEUE_BROKER_ID: broker-c ports: - '127.0.0.1:${BROKER_C_TCP_PORT:-18789}:6789' - '127.0.0.1:${BROKER_C_HTTP_PORT:-18790}:6790' sdk-example: profiles: [tools] build: context: ../.. dockerfile: examples/postgres-multibroker/client.Dockerfile entrypoint: ['bun', 'run', 'examples/postgres-multibroker/run.ts'] command: ['all'] environment: BUNQUEUE_TOKEN: demo-token BROKER_A_HOST: broker-a BROKER_A_PORT: '6789' BROKER_A_HTTP_URL: http://broker-a:6790 BROKER_B_HOST: broker-b BROKER_B_PORT: '6789' BROKER_B_HTTP_URL: http://broker-b:6790 BROKER_C_HOST: broker-c BROKER_C_PORT: '6789' BROKER_C_HTTP_URL: http://broker-c:6790 BUNQUEUE_EXAMPLE_SCENARIO_TIMEOUT_MS: ${BUNQUEUE_EXAMPLE_SCENARIO_TIMEOUT_MS:-60000} depends_on: broker-a: condition: service_healthy broker-b: condition: service_healthy broker-c: condition: service_healthy networks: [demo] volumes: postgres-data: networks: demo: internal: true ``` ### Why these details matter | Detail | Reason | | -------------------------------- | ------------------------------------------------------------------------------- | | `postgres:18.6-alpine` | The pinned and recommended PostgreSQL release | | `DATA_PATH: ''` | Prevents the image's SQLite default from conflicting with PostgreSQL mode | | One shared URL and namespace | Makes PostgreSQL the authoritative state for all three brokers | | Unique `BUNQUEUE_BROKER_ID` | Prevents session and lease-owner identity collisions | | `/ready` health check | Gates traffic on PostgreSQL and maintenance-loop health, not process life alone | | `AUTH_TOKENS` and `METRICS_AUTH` | Exercises authenticated TCP and Prometheus access | | `internal: true` network | Stops runtime containers from reaching the public internet | | Loopback host bindings | Keeps local demonstration ports off external interfaces | | Named PostgreSQL volume | Makes persistence explicit and teardown auditable | | Startup and scenario deadlines | Turn a stalled health gate or runner into a failing, cleanable command | ## Run it safely 1. **Start from the repository root** Docker must be running. No pre-existing bunqueue or PostgreSQL process is used. 2. **Execute the verifier** ```bash ./examples/postgres-multibroker/verify.sh ``` 3. **Read one JSON result per scenario** ```json { "durationMs": 7, "scenario": "topology", "status": "PASS" } ``` The duration is diagnostic functional timing, not a publishable benchmark. 4. **Confirm teardown** The script independently attempts resource teardown and local-image removal, then preserves the original scenario failure status. A cleanup failure makes an otherwise successful run fail. ## Complete verification script The trap is registered before the first Docker resource is created. ```bash title="examples/postgres-multibroker/verify.sh" #!/bin/sh set -eu EXAMPLE_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) PROJECT_NAME=${BUNQUEUE_EXAMPLE_PROJECT:-bunqueue-pg-example-$(date +%s)-$$} case "$PROJECT_NAME" in bunqueue-pg-example-?*) ;; *) echo "BUNQUEUE_EXAMPLE_PROJECT must start with bunqueue-pg-example-" >&2 exit 2 ;; esac case "$PROJECT_NAME" in *[!a-z0-9_-]*) echo "BUNQUEUE_EXAMPLE_PROJECT may contain only lowercase letters, digits, _ and -" >&2 exit 2 ;; esac compose() { docker compose --project-name "$PROJECT_NAME" --file "$EXAMPLE_DIR/compose.yaml" "$@" } cleanup() { original_status=$? trap - EXIT INT TERM set +e compose down --volumes --remove-orphans resources_status=$? compose down --volumes --remove-orphans --rmi local images_status=$? if [ "$resources_status" -ne 0 ] || [ "$images_status" -ne 0 ]; then echo "Example cleanup failed" >&2 fi if [ "$original_status" -ne 0 ]; then exit "$original_status" fi if [ "$resources_status" -ne 0 ] || [ "$images_status" -ne 0 ]; then exit 1 fi exit 0 } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM compose config --quiet compose --progress plain build compose up --detach --wait --wait-timeout 120 postgres broker-a broker-b broker-c for scenario in topology multi-queue reliability flow; do compose run --rm --no-deps sdk-example "$scenario" done compose ps ``` ## Host endpoints The SDK runner uses service DNS on the internal network. For manual inspection from the host, the Compose file publishes these loopback-only endpoints: | Broker | TCP | HTTP | | ------ | ----------------: | ----------------: | | A | `127.0.0.1:16789` | `127.0.0.1:16790` | | B | `127.0.0.1:17789` | `127.0.0.1:17790` | | C | `127.0.0.1:18789` | `127.0.0.1:18790` | Override a host port with `BROKER_A_TCP_PORT`, `BROKER_A_HTTP_PORT`, and the corresponding `B` or `C` variables. Internal container ports remain `6789` and `6790`. `BUNQUEUE_EXAMPLE_SCENARIO_TIMEOUT_MS` overrides the positive 60,000 ms runner deadline. A custom `BUNQUEUE_EXAMPLE_PROJECT` must begin with `bunqueue-pg-example-` and contain only lowercase letters, digits, `_`, and `-`. The verifier deliberately destroys every resource owned by that selected project, so never reuse the name of a project you intend to retain. ## Readiness is not liveness - `/healthz` answers whether the broker process can serve HTTP. - `/ready` returns `503` when persistent storage or a critical maintenance loop is degraded, so a load balancer should remove that broker. - `/prometheus` exposes operational metrics; this example requires the bearer token. Clients should be routed only to ready brokers. Killing an unhealthy process just because readiness failed can make a transient database incident noisier; keep liveness and readiness probes separate. Next: [connect multiple queues and workers](/examples/postgres-multibroker/queues-workers/). --- # Multiple Queues, Producers, and Workers Use the public bunqueue SDK across three PostgreSQL-backed brokers with typed queues, addBulk, priorities, delays, retries, progress, logs, results, QueueEvents, and worker discovery. URL: https://bunqueue.dev/examples/postgres-multibroker/queues-workers/ import { Aside, Code } from '@astrojs/starlight/components';
examples · sdk · queues and workers

N queues. N workers. One truth.

Put producers on broker A, workers on B and C, and observers on C. PostgreSQL keeps jobs, policies, lifecycle state, events, logs, and results coherent across the fleet.

## Connection factory Every SDK object receives one normal TCP connection. The SDK does not receive the PostgreSQL URL and does not need database credentials. ```typescript title="examples/postgres-multibroker/shared.ts" import type { ConnectionOptions } from 'bunqueue/client'; export type BrokerName = 'a' | 'b' | 'c'; export type CleanupTask = () => void | Promise; const defaults: Record = { a: { host: '127.0.0.1', port: 16789 }, b: { host: '127.0.0.1', port: 17789 }, c: { host: '127.0.0.1', port: 18789 }, }; export function connection(name: BrokerName): ConnectionOptions { const key = name.toUpperCase(); return { commandTimeout: 15_000, host: Bun.env[`BROKER_${key}_HOST`] ?? defaults[name].host, pingInterval: 0, poolSize: 2, port: Number(Bun.env[`BROKER_${key}_PORT`] ?? defaults[name].port), token: Bun.env.BUNQUEUE_TOKEN ?? 'demo-token', }; } export function httpUrl(name: BrokerName): string { const key = name.toUpperCase(); return ( Bun.env[`BROKER_${key}_HTTP_URL`] ?? `http://${defaults[name].host}:${defaults[name].port + 1}` ); } export function uniqueQueue(label: string): string { return `example-${label}-${crypto.randomUUID()}`; } export function invariant(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } export async function withTimeout( label: string, operation: () => T | Promise, timeoutMs: number ): Promise { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error(`Timeout for ${label} must be a positive finite number`); } let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`Timed out after ${Math.ceil(timeoutMs)}ms while ${label}`)), timeoutMs ); }); try { return await Promise.race([Promise.resolve().then(operation), timeout]); } finally { if (timer !== undefined) clearTimeout(timer); } } export async function waitFor( label: string, predicate: () => boolean | Promise, timeoutMs = 20_000 ): Promise { const deadline = performance.now() + timeoutMs; while (performance.now() < deadline) { const remaining = deadline - performance.now(); if (await withTimeout(`waiting for ${label}`, predicate, remaining)) return; await Bun.sleep(Math.min(25, Math.max(0, deadline - performance.now()))); } throw new Error(`Timed out waiting for ${label}`); } export async function settleCleanup( ...phases: ReadonlyArray> ): Promise { const errors: Error[] = []; for (const tasks of phases) { const results = await Promise.allSettled(tasks.map((task) => Promise.resolve().then(task))); for (const result of results) { if (result.status === 'rejected') { errors.push( result.reason instanceof Error ? result.reason : new Error(String(result.reason)) ); } } } if (errors.length > 0) throw new AggregateError(errors, 'Example cleanup failed'); } ``` The example deliberately names three endpoints so cross-broker behavior is observable. In production, pass a service or TCP load-balancer address instead when clients do not need deterministic placement. ## Realistic multi-queue scenario ```typescript title="examples/postgres-multibroker/multi-queue.ts" import { Queue, QueueEvents, Worker } from 'bunqueue/client'; import { connection, invariant, settleCleanup, uniqueQueue, waitFor } from './shared'; interface EmailJob { orderId: string; to: string; transient?: boolean; } interface MediaJob { assetId: string; width: number; } interface AuditJob { action: string; orderId: string; } export async function runMultiQueueExample(): Promise { const emailName = uniqueQueue('emails'); const mediaName = uniqueQueue('media'); const auditName = uniqueQueue('audit'); const emails = new Queue(emailName, { connection: connection('a') }); const media = new Queue(mediaName, { connection: connection('a') }); const audit = new Queue(auditName, { connection: connection('a') }); const emailObserver = new Queue(emailName, { connection: connection('c') }); const events = new QueueEvents<{ delivered: string }, { stage: string }>(emailName, { connection: connection('c'), }); const retried = new Set(); const completedEvents = new Set(); const progressEvents = new Set(); const emailStarts: string[] = []; const workerErrors: Error[] = []; events.on('completed', ({ jobId }) => completedEvents.add(jobId)); events.on('progress', ({ jobId }) => progressEvents.add(jobId)); const emailWorker = new Worker( emailName, async (job) => { emailStarts.push(job.id); await job.updateProgress(50, 'rendering-template'); await job.log(`sending order ${job.data.orderId}`); if (job.data.transient && !retried.has(job.id)) { retried.add(job.id); throw new Error('simulated provider timeout'); } await job.updateProgress(100, 'delivered'); return { delivered: job.data.to }; }, { batchSize: 1, concurrency: 1, connection: connection('b') } ); const mediaWorker = new Worker( mediaName, (job) => ({ output: `${job.data.assetId}-${job.data.width}.webp` }), { concurrency: 2, connection: connection('c') } ); const auditWorker = new Worker( auditName, () => ({ recorded: true }), { concurrency: 2, connection: connection('b') } ); for (const worker of [emailWorker, mediaWorker, auditWorker]) { worker.on('error', (error) => workerErrors.push(error)); } try { await Promise.all([ emails.waitUntilReady(), media.waitUntilReady(), audit.waitUntilReady(), events.waitUntilReady(), emailWorker.waitUntilReady(), mediaWorker.waitUntilReady(), auditWorker.waitUntilReady(), ]); await emails.pauseAsync(); await waitFor('the email pause to reach broker C', () => emailObserver.isPausedAsync()); const emailJobs = await emails.addBulk([ { name: 'order-confirmation', data: { orderId: 'ord-100', to: 'customer@example.com' }, opts: { durable: true, jobId: `${emailName}-normal` }, }, { name: 'vip-confirmation', data: { orderId: 'ord-101', to: 'vip@example.com' }, opts: { durable: true, jobId: `${emailName}-vip`, priority: 100 }, }, { name: 'provider-retry', data: { orderId: 'ord-102', to: 'retry@example.com', transient: true }, opts: { attempts: 3, backoff: 25, durable: true, jobId: `${emailName}-retry` }, }, { name: 'delayed-follow-up', data: { orderId: 'ord-103', to: 'later@example.com' }, opts: { delay: 300_000, durable: true, jobId: `${emailName}-delayed`, priority: 200, }, }, ]); const mediaJob = await media.add( 'thumbnail', { assetId: 'asset-7', width: 640 }, { durable: true } ); const auditJob = await audit.add( 'order-created', { action: 'created', orderId: 'ord-100' }, { durable: true } ); const delayedJob = emailJobs[3]; invariant( (await emailObserver.getJobState(delayedJob.id)) === 'delayed', 'the delayed email became eligible before promotion' ); invariant(emailStarts.length === 0, 'the paused email queue started work'); await emailObserver.resumeAsync(); await waitFor('all ready emails to complete in priority order', async () => { const states = await Promise.all(emailJobs.slice(0, 3).map((job) => job.getState())); return states.every((state) => state === 'completed'); }); invariant(emailStarts[0] === `${emailName}-vip`, 'the highest-priority ready email ran late'); invariant(!emailStarts.includes(delayedJob.id), 'the delayed email ran before promotion'); invariant( (await emailObserver.getJobState(delayedJob.id)) === 'delayed', 'the delayed email did not remain delayed' ); const beforePromotion = await emailObserver.getJobCountsAsync(); invariant( beforePromotion.completed === 3 && beforePromotion.delayed === 1, 'ready and delayed email counts diverged before promotion' ); await delayedJob.promote(); await waitFor('all queues to complete across three brokers', async () => { const [emailCounts, mediaCounts, auditCounts] = await Promise.all([ emailObserver.getJobCountsAsync(), media.getJobCountsAsync(), audit.getJobCountsAsync(), ]); return ( emailCounts.completed === emailJobs.length && mediaCounts.completed === 1 && auditCounts.completed === 1 ); }); await waitFor( 'cross-broker completion and progress events', () => completedEvents.size === emailJobs.length && progressEvents.size === emailJobs.length ); invariant(retried.has(`${emailName}-retry`), 'the transient email was not retried'); invariant(workerErrors.length === 0, `worker error: ${workerErrors[0]?.message}`); invariant(await mediaJob.isCompleted(), 'the media job did not complete'); invariant(await auditJob.isCompleted(), 'the audit job did not complete'); invariant((await emailObserver.getWorkersCount()) >= 1, 'no email worker was registered'); const logs = await emailObserver.getJobLogs(`${emailName}-normal`); invariant(logs.logs.includes('[info] sending order ord-100'), 'the email log was not retained'); const retained = await emailObserver.getJob(`${emailName}-normal`); invariant( retained?.returnvalue && (retained.returnvalue as { delivered: string }).delivered === 'customer@example.com', 'the cross-broker result was not retained' ); } finally { await settleCleanup( [ () => emailWorker.close(true), () => mediaWorker.close(true), () => auditWorker.close(true), () => events.close(), ], [ () => emails.obliterateAsync(), () => media.obliterateAsync(), () => audit.obliterateAsync(), ], [() => emails.close(), () => media.close(), () => audit.close(), () => emailObserver.close()] ); } } ``` ## What the assertions prove | Surface | Broker path | Assertion | | -------------- | ----------- | ------------------------------------------------------------------------ | | Email producer | A | Four durable jobs accepted with bulk, priority, delay, and retry options | | Email worker | B | One-slot priority order, delayed hold/promotion, retry, progress, logs | | Media worker | C | Independent queue and typed result | | Audit worker | B | Third queue processed independently | | QueueEvents | C | Completion and progress events converge for all email jobs | | Queue observer | C | Counts, worker registration, logs, state, and return value are visible | ## Scale workers independently Queue names define the work stream; worker processes do not need to live with their producer. Add replicas according to the workload: - more `emails` workers for network-bound delivery; - fewer `media` workers with lower concurrency for CPU or memory-heavy work; - separate `audit` workers with stricter retention and access controls. The broker-side queue concurrency limit is global across all brokers and workers. Worker `concurrency` controls only that worker instance. Use both when you need local capacity and a fleet-wide safety ceiling. ## Scheduling proof The email queue is paused before `addBulk()`, and broker C confirms that shared state before admission. The worker uses `concurrency: 1` and `batchSize: 1`, so the VIP job must be the first eligible start. The delayed follow-up has an even higher priority but remains in `delayed`; three ready emails complete while the counts stay at three completed and one delayed. Only an explicit `delayedJob.promote()` makes the fourth job eligible. This proves priority among ready work and proves that priority never bypasses a delay. ## Lifecycle rule Close workers first so in-flight outcomes can settle, then event subscriptions, then queues. The example obliterates its UUID-suffixed queues only because it is a disposable demonstration. A production shutdown must not obliterate queues. Next: [idempotency, retries, DLQ, and shared limits](/examples/postgres-multibroker/reliability/). --- # Multi-Broker Reliability Controls A verified bunqueue reliability example for concurrent custom IDs, global pause/resume, shared concurrency and rate limits, retries, DLQ inspection, and operator recovery. URL: https://bunqueue.dev/examples/postgres-multibroker/reliability/ import { Aside, Code } from '@astrojs/starlight/components';
examples · reliability

Retry safely. Recover deliberately.

Race producers through different brokers, control the queue globally, inspect failures from a peer, and retry only after the simulated payment provider is safe.

## Complete scenario ```typescript title="examples/postgres-multibroker/reliability.ts" import { Queue, Worker } from 'bunqueue/client'; import { connection, invariant, settleCleanup, uniqueQueue, waitFor, withTimeout } from './shared'; interface PaymentJob { amount: number; failUntilRetried?: boolean; mode?: 'concurrency' | 'rate'; } type WorkerBroker = 'b' | 'c'; export async function runReliabilityExample(): Promise { const queueName = uniqueQueue('payments'); const primary = new Queue(queueName, { autoBatch: { enabled: false }, connection: connection('a'), }); const peer = new Queue(queueName, { autoBatch: { enabled: false }, connection: connection('c'), }); const attempts = new Map(); const startedBy = new Map(); let allowRetry = false; let releaseConcurrency = (): void => undefined; const concurrencyGate = new Promise((resolve) => { releaseConcurrency = resolve; }); const processor = (broker: WorkerBroker) => async (job: { id: string; data: PaymentJob }) => { attempts.set(job.id, (attempts.get(job.id) ?? 0) + 1); startedBy.set(job.id, broker); if (job.data.mode === 'concurrency') await concurrencyGate; if (job.data.failUntilRetried && !allowRetry) throw new Error('payment provider refused'); return { charged: job.data.amount }; }; const workerB = new Worker(queueName, processor('b'), { autorun: false, batchSize: 1, concurrency: 1, connection: connection('b'), }); const workerC = new Worker(queueName, processor('c'), { autorun: false, batchSize: 1, concurrency: 1, connection: connection('c'), }); try { await Promise.all([primary.waitUntilReady(), peer.waitUntilReady(), workerB.waitUntilReady()]); await workerC.waitUntilReady(); await primary.setGlobalConcurrencyAsync(1); await waitFor( 'the concurrency cap to reach broker C', async () => (await peer.getGlobalConcurrency()) === 1 ); const concurrencyJobs = await primary.addBulk([ { name: 'concurrency-a', data: { amount: 1, mode: 'concurrency' }, opts: { durable: true } }, { name: 'concurrency-b', data: { amount: 2, mode: 'concurrency' }, opts: { durable: true } }, ]); workerB.run(); await waitFor('worker B to occupy the global concurrency slot', () => startedBy.has(concurrencyJobs[0].id) ); workerB.pause(); const concurrencyDrained = new Promise((resolve) => workerC.once('drained', resolve)); workerC.run(); await withTimeout( 'worker C observing an exhausted concurrency cap', () => concurrencyDrained, 5_000 ); const constrainedStates = await Promise.all( concurrencyJobs.map((job) => peer.getJobState(job.id)) ); invariant( constrainedStates.filter((state) => state === 'active').length === 1 && constrainedStates.filter((state) => state === 'waiting').length === 1, 'the global concurrency cap did not hold one active and one waiting job' ); invariant(await peer.isMaxed(), 'broker C did not report the exhausted concurrency cap'); invariant( ![...startedBy.values()].includes('c'), 'worker C started work while the global slot was occupied' ); releaseConcurrency(); await waitFor('both concurrency jobs to complete', async () => { const states = await Promise.all(concurrencyJobs.map((job) => primary.getJobState(job.id))); return states.every((state) => state === 'completed'); }); invariant( startedBy.get(concurrencyJobs[0].id) === 'b' && startedBy.get(concurrencyJobs[1].id) === 'c', 'the two brokers did not hand off the constrained work' ); await primary.removeGlobalConcurrencyAsync(); workerC.pause(); await primary.setGlobalRateLimitAsync(2, 300_000); await waitFor( 'the rate limit to reach broker C', async () => (await peer.getGlobalRateLimit())?.max === 2 ); workerB.resume(); const firstRateJob = await primary.add( 'rate-a', { amount: 3, mode: 'rate' }, { durable: true } ); await waitFor('worker B to consume the first rate token', async () => { return (await peer.getJobState(firstRateJob.id)) === 'completed'; }); invariant(startedBy.get(firstRateJob.id) === 'b', 'worker B did not consume the first token'); workerB.pause(); const remainingRateJobs = await peer.addBulk([ { name: 'rate-b', data: { amount: 4, mode: 'rate' }, opts: { durable: true } }, { name: 'rate-c', data: { amount: 5, mode: 'rate' }, opts: { durable: true } }, ]); const rateDrained = new Promise((resolve) => workerC.once('drained', resolve)); workerC.resume(); await withTimeout('worker C exhausting the shared rate window', () => rateDrained, 5_000); const rateStates = await Promise.all( remainingRateJobs.map((job) => primary.getJobState(job.id)) ); invariant( rateStates.filter((state) => state === 'completed').length === 1 && rateStates.filter((state) => state === 'waiting').length === 1, 'the fixed-window rate budget did not stop the third claim' ); invariant((await peer.getRateLimitTtl()) > 0, 'the shared rate window has no TTL'); await primary.removeGlobalRateLimitAsync(); await waitFor('the final rate-limited job to complete after limit removal', async () => { const states = await Promise.all(remainingRateJobs.map((job) => peer.getJobState(job.id))); return states.every((state) => state === 'completed'); }); invariant((await peer.getWorkersCount()) >= 2, 'both broker workers were not registered'); workerB.resume(); await primary.pauseAsync(); await waitFor('the paused state to reach broker C', () => peer.isPausedAsync()); const customId = `${queueName}-charge-once`; const [left, right] = await Promise.all([ primary.add('charge', { amount: 49 }, { durable: true, jobId: customId }), peer.add('charge', { amount: 49 }, { durable: true, jobId: customId }), ]); invariant( left.id === customId && right.id === customId, 'custom-ID admission was not idempotent' ); invariant((await peer.getJobCountsAsync()).paused === 1, 'the paused job was not shared'); await peer.resumeAsync(); await waitFor('the idempotent payment to complete', async () => { return (await primary.getJobState(customId)) === 'completed'; }); invariant(attempts.get(customId) === 1, 'the custom-ID job executed more than once'); const failed = await primary.add( 'charge-after-review', { amount: 125, failUntilRetried: true }, { attempts: 1, durable: true } ); await waitFor('the payment to reach the shared DLQ', async () => { return (await peer.getJobState(failed.id)) === 'failed'; }); invariant( (await peer.getDlqAsync()).some((entry) => entry.job.id === failed.id), 'the failed job was not visible from broker C' ); allowRetry = true; invariant((await peer.retryDlqAsync(failed.id)) === 1, 'the DLQ retry was not accepted'); await waitFor('the reviewed payment to complete', async () => { return (await primary.getJobState(failed.id)) === 'completed'; }); invariant(attempts.get(failed.id) === 2, 'the reviewed payment did not execute twice'); invariant((await primary.getDlqStatsAsync()).total === 0, 'the DLQ was not cleared'); } finally { releaseConcurrency(); await settleCleanup( [ () => primary.removeGlobalRateLimitAsync(), () => primary.removeGlobalConcurrencyAsync(), () => workerB.close(true), () => workerC.close(true), ], [() => primary.obliterateAsync()], [() => primary.close(), () => peer.close()] ); } } ``` ## Guarantees exercised ### Concurrent custom-ID admission Broker A and broker C concurrently add the same custom job ID. Both calls return that ID, PostgreSQL stores one live job generation, and the worker runs it once. This makes the queue admission idempotent; it does not automatically make an external payment API idempotent. ### Shared pause and limits Pause, global concurrency, and global rate limits live in PostgreSQL. The scenario writes them through broker A and waits for broker C to observe each configuration before testing behavior. For concurrency, Worker B occupies the single global slot with a blocked job, then pauses locally. Worker C reaches `drained`, broker C reports one active and one waiting job plus `isMaxed() === true`, and its processor has not started. Releasing B lets C claim the waiting job, proving the broker-to-broker handoff. For rate limiting, Worker B consumes the first token in a two-claim, five-minute PostgreSQL fixed window. Worker C consumes the second token and observes the third job still waiting with a positive TTL. Removing the global rate limit lets C finish that job; the example never waits for the five-minute window to expire. Finally, the queue pause prevents the idempotency job from being claimed until broker C resumes it. ### DLQ and operator retry The simulated payment fails with `attempts: 1`, reaches the shared DLQ, and is inspected through broker C. Only after `allowRetry` represents an operator or provider recovery does the example call `retryDlqAsync(id)`. Broker B executes the new attempt; broker A observes completion and an empty DLQ. ## Recommended processor pattern For an external side effect, derive a stable key from the queue job ID, send it to the provider, and persist or query the provider outcome. Throw only for a retryable condition. Send permanent validation failures directly to the DLQ or use one attempt; do not retry an invalid request until an operator changes the input or upstream state. ## Broker failure boundary The TypeScript SDK reconnects to the endpoint it was configured with. It does not accept a list of alternate broker hosts. Put a TCP service/load balancer in front of ready brokers, or implement endpoint selection in your application. Jobs remain authoritative in PostgreSQL; client reconnection and job lease recovery are separate concerns. Next: [build a durable cross-queue flow](/examples/postgres-multibroker/flows/). --- # Cross-Queue FlowProducer Graph Run and verify a durable three-level FlowProducer graph across three queues and three PostgreSQL-backed bunqueue brokers. URL: https://bunqueue.dev/examples/postgres-multibroker/flows/ import { Aside, Code } from '@astrojs/starlight/components';
examples · flow producer

Fan out. Transform. Converge.

Admit one atomic job graph through broker A, execute leaves through B, transform and publish through C, then read the final state and result through B.

## Graph ```text extract-sales (2) ─┐ ├─ transform-ledger (×2 = 10) ─┐ extract-costs (3) ─┘ ├─ publish-report (= 15) extract-forecast (5) ──────────────────────────────┘ ``` Children run before their parent. The report job cannot be claimed until both the transformed ledger and forecast child are complete. ## Complete scenario ```typescript title="examples/postgres-multibroker/flow.ts" import { FlowProducer, Queue, Worker } from 'bunqueue/client'; import { connection, invariant, settleCleanup, uniqueQueue } from './shared'; type FlowData = { stage?: string; value?: number }; export async function runFlowExample(): Promise { const extractQueue = uniqueQueue('extract'); const transformQueue = uniqueQueue('transform'); const reportQueue = uniqueQueue('report'); const effects: string[] = []; const executions = new Map(); const mark = (id: string): void => { effects.push(id); executions.set(id, (executions.get(id) ?? 0) + 1); }; const extractWorker = new Worker( extractQueue, (job) => { mark(job.id); return Number(job.data.value); }, { concurrency: 4, connection: connection('b') } ); const transformWorker = new Worker( transformQueue, async (job) => { mark(job.id); const values = await job.getChildrenValues(); return Object.values(values).reduce((sum, value) => sum + value, 0) * 2; }, { concurrency: 2, connection: connection('c') } ); const reportWorker = new Worker( reportQueue, async (job) => { mark(job.id); const values = await job.getChildrenValues(); return Object.values(values).reduce((sum, value) => sum + value, 0); }, { concurrency: 1, connection: connection('c') } ); const flow = new FlowProducer({ connection: connection('a') }); const observer = new Queue(reportQueue, { connection: connection('b') }); try { await Promise.all([ extractWorker.waitUntilReady(), transformWorker.waitUntilReady(), reportWorker.waitUntilReady(), flow.waitUntilReady(), observer.waitUntilReady(), ]); const suffix = crypto.randomUUID(); const root = await flow.add({ children: [ { children: [ { data: { value: 2 }, name: 'extract-sales', opts: { durable: true, jobId: `extract-sales-${suffix}` }, queueName: extractQueue, }, { data: { value: 3 }, name: 'extract-costs', opts: { durable: true, jobId: `extract-costs-${suffix}` }, queueName: extractQueue, }, ], data: { stage: 'transform' }, name: 'transform-ledger', opts: { durable: true, jobId: `transform-${suffix}` }, queueName: transformQueue, }, { data: { value: 5 }, name: 'extract-forecast', opts: { durable: true, jobId: `extract-forecast-${suffix}` }, queueName: extractQueue, }, ], data: { stage: 'report' }, name: 'publish-report', opts: { durable: true, jobId: `report-${suffix}` }, queueName: reportQueue, }); invariant((await root.job.waitUntilFinished(null, 20_000)) === 15, 'wrong flow result'); invariant(executions.size === 5, 'not every flow node executed'); invariant( [...executions.values()].every((count) => count === 1), 'a flow node executed more than once' ); const transformNode = root.children?.[0]; invariant(transformNode, 'the transform node is missing'); const transformId = transformNode.job.id; const reportId = root.job.id; for (const leaf of transformNode.children ?? []) { invariant(effects.indexOf(leaf.job.id) < effects.indexOf(transformId), 'transform ran early'); } invariant(effects.indexOf(transformId) < effects.indexOf(reportId), 'report ran early'); invariant((await observer.getJobState(reportId)) === 'completed', 'broker B missed the report'); const tree = await flow.getFlow({ depth: 3, id: reportId, queueName: reportQueue }); invariant(tree?.children?.length === 2, 'the persisted flow tree is incomplete'); invariant((await flow.getParentResult(reportId)) === 15, 'the result is not durable'); } finally { const extract = new Queue(extractQueue, { connection: connection('a') }); const transform = new Queue(transformQueue, { connection: connection('a') }); const report = new Queue(reportQueue, { connection: connection('a') }); await settleCleanup( [ () => extractWorker.close(true), () => transformWorker.close(true), () => reportWorker.close(true), () => flow.close(), ], [ () => extract.obliterateAsync(), () => transform.obliterateAsync(), () => report.obliterateAsync(), ], [() => extract.close(), () => transform.close(), () => report.close(), () => observer.close()] ); } } ``` ## What is verified - `FlowProducer.add()` commits all five nodes as one durable graph. - each node executes exactly once in the successful campaign; - both extraction leaves finish before `transform-ledger`; - the transform finishes before `publish-report`; - `job.getChildrenValues()` returns authoritative child results; - broker B observes the root as completed even though broker C processed it; - `getFlow()` reconstructs the persisted tree to depth three; - `getParentResult()` returns the durable final value `15`. ## Design guidance Keep leaf payloads small and place large artifacts in object storage. Return references and structured summaries as child results. Use deterministic custom IDs when the same business graph may be submitted twice. Processor effects still need application-level idempotency for crash-after-effect recovery. For long-running business orchestration with signals, human approval, loops, and compensation, use the [Workflow Engine](/guide/workflow/). `FlowProducer` is the smaller primitive for queue-native dependency graphs. Next: [operate and scale the fleet](/examples/postgres-multibroker/operations/). --- # Operate and Scale N Brokers Production checklist for scaling PostgreSQL-backed bunqueue brokers: identities, connection budgets, routing, readiness, security, observability, upgrades, backups, and failure drills. URL: https://bunqueue.dev/examples/postgres-multibroker/operations/ import { Aside } from '@astrojs/starlight/components';
examples · production operations

Scale the fleet without breaking its invariants.

The SDK code stays ordinary. Production quality comes from stable broker identity, bounded database connections, correct traffic routing, coordinated upgrades, and rehearsed recovery.

## N-broker contract Before adding a broker, verify all of these: | Invariant | Required value | | ---------------- | -------------------------------------------------------------- | | Storage driver | `postgres` | | PostgreSQL URL | Same primary database or HA endpoint | | Namespace | Same for brokers that must share queues | | Broker ID | Unique, stable, and never concurrently reused | | bunqueue binary | Same version across the active fleet | | SQLite data path | Unset or explicitly empty | | Clock | PostgreSQL time is authoritative for leases | | Network | Brokers reach PostgreSQL; clients reach TCP; probes reach HTTP | Do not use `docker compose up --scale broker=N` on a service with one static `BUNQUEUE_BROKER_ID`. Explicit services are safe for a local fleet. In an orchestrator, inject a stable Pod or task identity. ## Connection budget The default PostgreSQL pool is four connections per broker. Start with: ```text required database connections = broker replicas × pool size + migrations and administration + monitoring and failover headroom ``` Ten brokers at pool size four require 40 queue connections before operational headroom. A larger pool is not automatically faster: it can increase database contention, WAL pressure, and tail latency. Tune against the real payload, batch size, broker count, and PostgreSQL latency. ## Traffic routing Expose one TCP service or L4 load balancer and include only `/ready` brokers in its backend set. TCP balancing happens per connection, not per command; a long-lived SDK pool can remain uneven during low connection churn. Watch broker-local process and connection metrics in addition to PostgreSQL-global job-state metrics. ## Security checklist - keep PostgreSQL private and require verified TLS, such as `sslmode=verify-full`, according to the provider contract; - keep raw database passwords out of Compose files and percent-encode reserved URL password characters; - enable TCP authentication and protect metrics, REST, SSE, and WebSocket surfaces; - terminate client TLS at bunqueue or a trusted internal proxy; - bind no debug or database ports to public interfaces; - run as a non-root user with a read-only root filesystem where the platform supports it; - rotate credentials and test the rollout without mixing broker versions. ## Observability Alert on behavior, not just process presence: | Signal | Investigate when | | ------------------------------ | ----------------------------------------------------------- | | `/ready` | Any broker stays non-ready beyond a short database incident | | Queue depth and oldest age | Backlog grows or violates the business SLO | | Active jobs and lease recovery | Active work stalls or recovery frequency rises | | DLQ size and failure rate | Permanent or exhausted failures appear | | Command latency/errors | TCP operations approach client deadlines | | PostgreSQL connections | Pool budget nears `max_connections` | | Locks/deadlocks | Lock wait grows or any new deadlock appears | | WAL, dead tuples, vacuum lag | Queue churn outruns maintenance | | Replica replay lag | HA replicas cannot meet recovery expectations | ## Upgrades Schema initialization is automatic, but mixed bunqueue versions are not a supported steady state. Test backup/PITR restoration on a clone, drain or stop the old fleet, start one new broker, wait for readiness and validate counts, then start the remaining brokers at exactly the same version. An old binary may refuse a newer schema; restoring the application alone is not a rollback plan. ## Backups and disaster recovery Use PostgreSQL-native physical or managed-service backups and PITR. The bunqueue SQLite S3 snapshot feature does not back up PostgreSQL mode. Rehearse a restore into an isolated cluster, start one broker, verify state and health, then add the rest of the fleet. A point-in-time restore can replay a job whose later ACK fell outside the recovery point, so external effects must be idempotent. ## Failure drills before production 1. Kill a broker that owns active leases and verify recovery plus stale-token rejection. 2. Reset PostgreSQL connections and confirm bounded client errors and reconnection. 3. Fill the DLQ and rehearse filtered inspection, repair, retry, and audit. 4. Pause a queue through one broker and verify all brokers honor it. 5. Restore PostgreSQL to a fresh endpoint and validate job conservation. 6. Simulate a database outage long enough to exercise readiness and caller retry budgets. The repository has deeper automated PostgreSQL crash and contention campaigns; the disposable example intentionally stays fast enough for onboarding. Next: [read the engineering validation report](/examples/postgres-multibroker/validation/). --- # Engineering Validation Report Engineering report for the PostgreSQL multi-broker examples: scope, environment, exact commands, scenario results, findings, teardown proof, limitations, and verdict. URL: https://bunqueue.dev/examples/postgres-multibroker/validation/ import { Aside } from '@astrojs/starlight/components';
examples · engineering report · 2026-08-30

Tested end to end. Nothing left running.

Functional evidence for the exact sources rendered in this section, including the first-run findings, final passing matrix, environment, cleanup audit, and exclusions.

## Verdict **PASS for the documented functional scope.** PostgreSQL 18.6 and three active bunqueue brokers shared queue state correctly across all four executable scenarios. The final run returned exit code `0`. Its exit trap removed every project container, network, volume, and locally built image. A separate forced timeout campaign returned exit code `1` and removed the same resource classes. ## Environment | Field | Recorded value | | ------------------------------------- | ------------------------------------------------ | | Date | 2026-08-30 | | Host | macOS Darwin 25.6.0, arm64 | | Docker client/server | 29.4.0 / 29.4.0 | | Container OS/architecture | Linux / arm64 | | Bun | 1.4.0 (`34cbb9a40` reported by the image) | | PostgreSQL image | `postgres:18.6-alpine` | | Broker count | 3 independent containers | | Broker storage | One PostgreSQL database and one shared namespace | | Runtime network | Dedicated Compose network with `internal: true` | | Repository mounts | None | | Docker socket/credentials/home mounts | None | ## Exact gate ```bash ./examples/postgres-multibroker/verify.sh ``` Internally, the script validates Compose, builds fresh broker and SDK images, waits for PostgreSQL and all three `/ready` probes, runs each scenario in its own client container, prints the fleet state, and tears the project down. Static checks run before the Docker gate: ```bash bunx oxfmt --check examples/postgres-multibroker/*.ts bunx oxlint examples/postgres-multibroker/*.ts bunx tsc --noEmit --strict --target ESNext --module ESNext \ --moduleResolution bundler --types bun-types --skipLibCheck \ examples/postgres-multibroker/*.ts docker compose -f examples/postgres-multibroker/compose.yaml config --quiet bun test test/postgres-multibroker-example.test.ts git diff --check ``` ## Documentation discovery gate The publication build runs the discovery audit automatically: ```bash cd docs bun run build ``` Final result: **118 Astro pages built**, followed by a passing comparison of **102 full-text documentation pages**, **117 sitemap URLs**, and **6 inlined executable sources**. The audit derives expected routes from the content tree, requires unique canonical URLs, checks the seven multi-broker pages in reading order, proves that every `?raw` source is present in `llms-full.txt`, validates all internal links in the curated `llms.txt`, and confirms the sitemap and robots discovery pointers. ## Final scenario matrix | Scenario | Result | Runner duration | Functional assertions | | ------------- | ------ | --------------: | --------------------------------------------------------------------------------------------------- | | `topology` | PASS | 8 ms | 3× bounded liveness/readiness, unauthenticated metrics denied, authenticated metrics returned | | `multi-queue` | PASS | 613 ms | 3 queues/workers, priority order, delayed hold/promotion, retry, progress, logs, events, results | | `reliability` | PASS | 1,492 ms | concurrency handoff, fixed-window rate enforcement, custom-ID race, pause, DLQ, operator retry | | `flow` | PASS | 551 ms | 5 durable nodes, 3 levels, child ordering, exactly-once successful execution, tree and result reads | These durations are emitted by `performance.now()` inside the isolated SDK runner. Image build, service startup, and teardown are excluded. They are reported only to identify hangs or regressions in the example workflow. ## Findings and corrections The examples were not published on their first draft: 1. Static lint found unnecessary asynchronous callbacks and unsafe non-null assertions. The callbacks became synchronous and graph structure is now asserted before access. 2. The first container campaign passed the first three functional scenarios, but the one-shot reliability runner remained attached after printing `PASS`. The CLI entrypoint now exits with status `0` only after scenario cleanup completes, and exits `1` after cleanup on any thrown assertion. 3. The interrupted first campaign's targeted runner was stopped, after which the installed trap removed all remaining project resources. A completely fresh second campaign then passed all four scenarios and normal teardown. The final publication campaign repeated that result after the docs and verifier were frozen; its measurements appear in the matrix above. 4. Mandatory pre-commit review reproduced an inherited-property CLI bug: `toString` could be accepted as a scenario and falsely report `PASS`. The registry now uses `Object.hasOwn`, the module is import-safe, and regressions reject `toString`, `constructor`, `__proto__`, and an ordinary unknown name while exercising every valid selection in order. 5. The same review showed that a synchronous cleanup throw could skip later callbacks and phases. Cleanup now captures synchronous and asynchronous failures, settles every task in a phase, continues later phases in order, and throws one aggregate error. The verifier independently attempts resource and image removal while preserving the original failure status. 6. Configuration-only checks for priority, delay, global concurrency, and rate limits were replaced with behavioral assertions. HTTP calls, polling predicates, startup, and each scenario also gained explicit deadlines. ## Failure-path campaign The real Compose topology was also run with an intentionally impossible one-millisecond scenario budget: ```bash BUNQUEUE_EXAMPLE_PROJECT=bunqueue-pg-example-timeout-audit \ BUNQUEUE_EXAMPLE_SCENARIO_TIMEOUT_MS=1 \ ./examples/postgres-multibroker/verify.sh ``` The topology runner raised `Timed out after 1ms while running scenario topology`, the verifier returned exit code `1`, and its exit trap removed all containers, the PostgreSQL volume, the internal network, and all four locally built images. Eleven focused unit regressions separately cover hostile CLI names, valid ordering, scenario and predicate deadlines, HTTP aborts, multi-phase cleanup, original-status preservation, cleanup fallback, and project-name rejection. ## Cleanup evidence After both the forced-timeout and final passing commands returned, four independent filters produced no output: ```bash docker ps -a --format '{{.Names}}' | rg '^bunqueue-pg-example-' docker volume ls --format '{{.Name}}' | rg '^bunqueue-pg-example-' docker network ls --format '{{.Name}}' | rg '^bunqueue-pg-example-' docker images --format '{{.Repository}}:{{.Tag}}' | rg '^bunqueue-pg-example-' ``` Residual example resources: **0 containers, 0 volumes, 0 networks, 0 local project images**. The shared pulled base images are Docker cache inputs and are not project resources. ## Coverage boundaries Covered here: - real public TypeScript SDK built from the current source; - real TCP transport with authentication; - real PostgreSQL schema, transactions, event journal, queue policies, leases, results, logs, and DLQ; - three independent server processes and explicit cross-broker reads/writes; - application cleanup and infrastructure teardown. Not covered here: - PostgreSQL primary or replica failover; - broker `SIGKILL` while it owns an active lease; - mixed-version rollout, backup restore, TLS PKI, or external secret manager; - all six external language SDKs; - sustained load, capacity, latency distributions, or memory-leak proof. Use the repository PostgreSQL integration, fast-check, multi-process crash, ten-broker, SDK conformance, and sandbox gates for those broader contracts. ## Reproduction Run the same gate at any time from a clean worktree: ```bash ./examples/postgres-multibroker/verify.sh ``` Every run receives a timestamp/PID-derived Compose project name, so it does not reuse a prior database or collide with a normal bunqueue Compose project. The script's final status is the scenario status; teardown is mandatory on every exit path. --- # Architecture: SQLite and PostgreSQL Queue Engines for Bun Architecture overview of bunqueue: the sharded memory/SQLite engine, PostgreSQL multi-broker manager, TCP protocol, and scheduling internals. URL: https://bunqueue.dev/architecture/
architecture · overview

Inside the bunqueue architecture.

bunqueue has two execution topologies behind one server protocol: a synchronous sharded memory/SQLite engine, and a database-authoritative PostgreSQL 15–18 engine for multiple brokers. This section maps both and identifies which diagrams belong to which path.

## System Overview
System overviewclient, server, persistence
client layer
Queue.add() TcpPool
Worker.process() TcpPool
↓ msgpack over TCP :6789
server layer
QueueManager · memory / SQLite
N shards auto-detected: Shard 0, Shard 1, ... Shard N
jobIndex
completedJobs
customIdMap
jobResults
local persistence path
WriteBuffer
SQLite WAL mode
PostgreSQL multi-broker path
PostgresQueueManager
Bun.SQL pool + transactions
PostgreSQL authoritative state
background tasks
Scheduler
Stall detection
DLQ maintenance
Cleanup
## Layered Architecture | Layer | Purpose | Key Components | | ------------------ | ---------------------------- | ---------------------------------------- | | **Client** | SDK for applications | Queue, Worker, FlowProducer, TcpPool | | **Server** | Request handling | TcpServer, HttpServer, Handlers | | **Application** | Orchestration | QueueManager, Operations, Managers | | **Domain** | Business logic | Shard, PriorityQueue, DLQ | | **Infrastructure** | Storage and external systems | SQLite, PostgreSQL, S3 Backup, Scheduler | | **Shared** | Utilities | Hash, Lock, LRU, MinHeap | ## Architecture Sections | Section | Description | | ----------------------------------------------------- | ----------------------------------------------------------- | | [Client SDK](/architecture/client-sdk/) | TCP connection, job submission, worker processing | | [Domain Layer](/architecture/domain-layer/) | Sharding, priority queues, DLQ logic | | [Application Layer](/architecture/application-layer/) | Operations flow, background tasks | | [Persistence](/architecture/persistence/) | SQLite configuration, write buffering, and recovery | | [Storage Backends](/guide/databases/) | PostgreSQL transactions, multi-broker fencing, and topology | | [Data Structures](/architecture/data-structures/) | Core algorithms and complexities | | [TCP Protocol](/architecture/tcp-protocol/) | Wire format and commands | | [Cron Scheduler](/architecture/cron-scheduler/) | Event-driven scheduling, timezone support, persistence | ## Key Design Decisions The shard, heap, lock, write-buffer, and complexity sections below describe the memory/SQLite `QueueManager`. PostgreSQL servers select `PostgresQueueManager` instead: PostgreSQL owns claim ordering, leases, shared policy, dependencies, events, cron, and terminal state. The TCP/HTTP client contract remains common. ### Dynamic Shard Architecture In memory/SQLite mode, jobs are distributed across N independent shards (auto-detected from CPU cores) using FNV-1a hash: ``` SHARD_COUNT = calculateShardCount() // Power of 2, based on CPU cores, max 64 SHARD_MASK = SHARD_COUNT - 1 shardIndex = fnv1a(queueName) & SHARD_MASK // src/shared/hash.ts // Examples: 4 cores → 4 shards, 10 cores → 16 shards, 64+ cores → 64 shards ``` **Benefits:** - Auto-scales with hardware (power of 2, max 64) - Parallel operations on different queues - Reduced lock contention - Bitwise AND faster than modulo ### 4-ary Priority Queue Each shard contains a 4-ary heap instead of binary: - Better cache locality (children fit in cache line) - Fewer tree levels (8 vs 16 for 65k items) - O(log₄ n) operations ### SQLite Write Buffer Jobs batch before SQLite write:
Buffer 100 jobs
Multi-row INSERT 186,384 jobs/s median in the published public on-disk addBulk workload

Flushes after 10ms or when 100 jobs are buffered, whichever comes first.

- **Buffered**: up to 10 ms loss risk; 186,384 jobs/s median in the published public on-disk Embedded `addBulk` workload - **Durable**: immediate persistence; 60,835 ops/s median for published sequential Embedded adds ### Lazy Deletion Heap entries use generation tracking: ``` Remove: Delete from index (O(1)), mark heap entry stale Pop: Skip entries where generation != current Compact: Rebuild when >20% stale ``` ## Lock Hierarchy Acquire in order to prevent deadlocks: ``` 1. jobIndex (read-only) 2. completedJobs (check before lock) 3. shardLocks[N] 4. processingLocks[N] ``` ## Memory Bounds | Collection | Limit | Eviction | | ------------- | ------ | ---------- | | completedJobs | 50,000 | FIFO batch | | jobResults | 10,000 | LRU | | jobLogs | 10,000 | LRU | | customIdMap | 50,000 | LRU | | DLQ per queue | 10,000 | FIFO | ## Memory/SQLite Performance Summary | Operation | Complexity | | ---------- | ---------- | | PUSH | O(log₄ n) | | PULL | O(log₄ n) | | ACK | O(1) | | ACK batch | O(shards) | | Job lookup | O(1) | | Stats | O(1) | :::tip[Related] - [Client SDK Architecture](/architecture/client-sdk/) - Queue, Worker, and connection pool - [TCP Protocol Architecture](/architecture/tcp-protocol/) - Binary protocol and commands - [SQLite Persistence Layer](/architecture/persistence/) - Write buffer and WAL mode - [SQLite or PostgreSQL](/guide/databases/) - Storage selection and multi-broker guarantees - [Core Data Structures](/architecture/data-structures/) - Skip lists, heaps, and LRU caches - [Cron Scheduler](/architecture/cron-scheduler/) - Event-driven scheduling internals ::: --- # Client SDK Architecture: Pooling & Worker Modes bunqueue Client SDK internals: TCP connection pooling, embedded vs server mode, worker heartbeats, ACK batching, and auto-batching. URL: https://bunqueue.dev/architecture/client-sdk/
architecture · client sdk

Thin client, smart server.

The client layer provides the interface for applications to interact with bunqueue. It supports both embedded (in-process) and TCP (server) modes.

## Module Structure ``` src/client/ ├── queue/ # Job submission (Queue class) ├── worker/ # Job processing (Worker class) ├── tcp/ # Network communication ├── flow.ts # Job dependencies (FlowProducer) └── queueGroup.ts # Namespace isolation ``` ## Dual-Mode Architecture
Dual-mode architectureone API, two transports
Application Queue.add(), Worker.process()
Embedded mode direct calls to QueueManager
TCP mode TcpPool → Server, msgpack protocol
| Mode | Published workload median | Use case | | ----------------- | ---------------------------------------: | ------------------------------- | | Embedded + SQLite | 186,384 jobs/s, public on-disk `addBulk` | Single process | | TCP + SQLite | 158,779 jobs/s, `PUSHB` | Distributed clients, one broker | | TCP + PostgreSQL | See the multi-broker benchmark matrix | Distributed clients and brokers | These are workload-specific ingestion medians, not a universal per-job rate. See [Engineering Benchmarks](/guide/benchmarks/) for distributions and lifecycle throughput. ## Job Submission Flow
Job submissionQueue.add(name, data, options)
Merge options with defaults
Mode check
Embedded direct manager.push()
TCP tcpPool.send({ cmd: 'PUSH', queue, data, priority, delay, ...options })
Return Job with methods

Auto-batching (TCP mode, on by default): concurrent add() calls are transparently coalesced into a single PUSHB round-trip (defaults: maxSize 50, maxDelayMs 5). Sequential awaits send immediately with no penalty; durable jobs bypass the batcher and go out as individual PUSH.

## Worker Processing Flow
Worker processing loopWorker(queue, processor, options)
Start heartbeat timer default: 10s interval
Poll loop respects concurrency limit
Pull batch from server PULLB command
for each job
1. Mark active
2. Execute processor(job)
3. On success: ACK batch
4. On failure: FAIL

After each batch the worker returns to the poll loop.

## Connection Pool Architecture
TcpConnectionPool4 connections per pool, default
Client 1 socket, parser, health
Client 2 socket, parser, health
Client 3 socket, parser, health
Client 4 socket, parser, health
Round-robin selection, health tracking, auto-reconnect, shared pool management
**Key Features:** - 4 connections per pool (default) - Load-aware client selection - Automatic reconnection with exponential backoff - Shared pools across Queue/Worker instances ## Heartbeat & Stall Detection
Heartbeat and stall detectionworker to server
worker
heartbeatTimer every 10s sends JobHeartbeatB { ids, tokens } to the server
pulledJobIds all pulled jobs get heartbeat
activeJobIds jobs being processed
jobTokens lock tokens for verification
server
No heartbeat for stallInterval (30s): 1. mark job as stalled, 2. increment stallCount, 3. after maxStalls (3) move to DLQ
## ACK Batching Flow
ACK batchingjob completes
AckBatcher.queue(id, result)
Buffer pending ACKs min(configured size, reachable-outcome frontier), or 50ms timeout
Batch full
Timer fires
Send ACKB { ids, results, tokens }
**Benefits:** - Reduces network round-trips - Batches lock verification - Handles retry on failure ## FlowProducer (Dependencies) The Bun client plans the complete graph and commits it through one `PUSHF` operation in both embedded and TCP modes. Validation occurs before mutation; in memory/SQLite mode all affected shard locks are held through publication, and configured SQLite commits every node before workers are notified. A PostgreSQL server instead commits the complete graph and ownership edges in one database transaction, then refreshes its projection; it does not use the base manager's shard locks. All six current external SDKs use the same atomic command. `PUSH` plus `UpdateParent` remains compatible for previously published clients; a predeclared late edge is a child-only durable back-patch and never rewrites an active or terminal parent.
Dependency chainaddChain([A, B, C])
A no dependencies, queued
B dependsOn: [A]
C dependsOn: [B]
Server tracks in waitingDeps until dependencies complete
## Graceful Shutdown
Graceful shutdownworker.close()
1. Stop poll loop
2. Stop heartbeat
3. Wait active jobs finish
4. Flush pending ACKs
5. Wait in-flight flushes
6. Close TCP connections
:::tip[Related] - [Architecture Overview](/architecture/) - How every component fits together - [TCP Protocol](/architecture/tcp-protocol/) - The wire format the connection pool speaks - [Application Layer](/architecture/application-layer/) - What the server does with pulled jobs - [Queue API](/guide/queue/) - The client-facing API built on this pool ::: --- # Memory/SQLite Domain Layer: Sharding, Queues & States bunqueue memory/SQLite domain internals: auto-scaled sharding, 4-ary priority queues, job state machine, DLQ flow, and rate limiting logic. URL: https://bunqueue.dev/architecture/domain-layer/
architecture · domain layer

The domain layer, no I/O.

The memory/SQLite domain layer contains pure queue logic: no I/O, just core algorithms and data structures. PostgreSQL shares the public job model but owns ordering and coordination in database transactions.

This page describes the base memory/SQLite engine. PostgreSQL servers use the same public states and payload types, but authoritative queues, limits, leases, and claims live in PostgreSQL rather than these in-memory shards. See [Storage backends](/guide/databases/) and the [application layer](/architecture/application-layer/). ## Module Structure ``` src/domain/ ├── types/ # Type definitions └── queue/ # Core queue logic ├── shard.ts # Shard container ├── priorityQueue.ts # 4-ary indexed heap ├── dlqShard.ts # Dead letter queue ├── uniqueKeyManager.ts # Deduplication ├── limiterManager.ts # Rate/concurrency ├── groupLimiterManager.ts # Per-group rate/concurrency ├── groupScheduler.ts # Secondary priority/FIFO lanes + rotation ├── dependencyTracker.ts # Job dependencies ├── temporalManager.ts # Temporal index + delayed jobs ├── waiterManager.ts # Long-poll waiters └── shardCounters.ts # Running shard totals ``` ## Sharding Architecture In memory/SQLite mode, jobs are distributed across N shards (auto-detected from CPU cores) for parallelism:
QueueManagerN independent shards, auto-detected
queueName
fnv1a()
& SHARD_MASK
idx
Shard 0 queues, unique, dlq, limits
Shard 1 queues, unique, dlq, limits
Shard 2 queues, unique, dlq, limits
Shard N queues, unique, dlq, limits

Shard count is a power of 2, based on CPU cores, max 64.

### Shard Composition Each shard is a composition of managers:
Shardcomposition of managers
queues Map<string, PriorityQueue>
UniqueKeyManager deduplication with TTL
DlqShard failed job storage
LimiterManager rate and concurrency control
DependencyTracker waitingDeps + dependencyIndex
TemporalManager delayed jobs, MinHeap
stats running shard totals: queued, delayed, dlq
group ownership active set + authoritative counts
waiters queue-local cursor deques, long-poll support
The shard counters make aggregate queued, delayed, and DLQ totals constant-time. Splitting ready jobs into `waiting` versus `prioritized` still examines current queue entries. Multi-queue summary calls batch that work and traverse global processing/completed/dependency collections once, instead of once per queue. ## Priority Queue Flow 4-ary indexed heap with lazy deletion:
PriorityQueue4-ary indexed heap with lazy deletion
PUSH
1. Generate generation number, 2. add to index Map<jobId, {job, generation}>, 3. push to heap {jobId, priority, runAt, generation}, 4. bubbleUp O(log₄ n)
POP
Loop: 1. peek heap top, 2. check index for matching generation, 3. if generation mismatch, stale entry: removeTop, continue, 4. if match: removeTop, delete from index, return job O(log₄ n) amortized
REMOVE, by jobId
1. Delete from index O(1), 2. heap entry becomes stale skipped on pop, 3. compact heap when stale ratio > 20%
## Long-Poll Waiters Waiters are isolated by queue. Each queue keeps an append-only entry array with a head cursor, an active count, and one coalesced pending-notification bit. Notification clears a waiter's timer immediately and advances the cursor; it does not repeatedly filter or splice the full array. Consumed prefixes are compacted once the head reaches 1,024 entries and at least half the array is stale. Surplus batch notifications collapse into one retry hint rather than accumulating credits that would cause repeated empty pulls. ## Job State Machine
Job state machine
WAITING re-entered when a retryable fail triggers retry
DELAYED delay > 0, becomes ready when runAt is reached
ready delay = 0
ACTIVE on retryable fail, back to WAITING
COMPLETED success
DLQ fail at max retries, or timeout
## Dependency Resolution Flow
Dependency resolutionJob B, dependsOn: [A]
push B, job with dependencies
1. Push B, check: is A completed?
NO add B to waitingDeps, register B in dependencyIndex[A]
YES push B to active queue
when A completes
1. Add A.id to pendingDepChecks
2. Event-driven flush scheduled on the next microtask, coalescing completions from the same tick; a 30s interval acts as safety fallback only
3. For each completedId, get dependencyIndex[completedId] Set<jobIds>
4. For each waiting job, check all deps in completedJobs, if YES move from waitingDeps to queue
**Reverse Index:**
Reverse indexdependencyIndex: Map<JobId, Set<JobId>>
A
{B, C} B and C wait for A
D
{E} E waits for D
## DLQ (Dead Letter Queue) Flow
Move to DLQjob fails with attempts >= maxAttempts
DlqEntry
job original job
reason explicit_fail, max_attempts_exceeded, timeout, stalled, ttl_expired, worker_lost, unknown
error error message
attempts full history: attempt, error, duration
enteredAt timestamp
nextRetryAt if autoRetry enabled
expiresAt 7 days default
DLQ maintenance, every 60s
1. Auto-retry eligible entries nextRetryAt <= now && retryCount < maxAutoRetries
2. Purge expired entries expiresAt <= now
3. Enforce maxEntries per queue 10k default, FIFO eviction when full
## Rate & Concurrency Limiting
Pull requestrate and concurrency limiting
1. check rate limit, token bucket
Tokens available consume 1, proceed
No tokens return null
2. check concurrency limit
active < limit increment, proceed
At limit return null
3. Pop from priority queue
token bucket
capacity N tokens
refillRate N tokens/sec
tryAcquire() 1. refill based on elapsed time, 2. if tokens >= 1 consume and return true, 3. else return false
## FIFO Groups Groups preserve claim order within each group without making group execution serial by default. Active ownership is counted: `activeGroupCounts` is the authoritative per-group count, while `activeGroups` is its set-shaped view for telemetry and membership. With no group concurrency option, the limit is unbounded. A Worker can supply a default per-group concurrency cap, and an explicit server-side override can replace it for one group. Per-group fixed window rate limits are checked by the same eligibility path.
Priority/FIFO groupssecondary lanes over the authoritative queue
PULL
1. Promote due secondary entries, 2. serve ready ungrouped work first, 3. otherwise inspect the next group in circular rotation
Ineligible group keep its priority/FIFO head in place and rotate to another group
Eligible group claim its priority/FIFO head, increment ownership, advance the round-robin cursor
ACK / FAIL
1. Decrement the authoritative ownership count, 2. remove set membership only when the count reaches zero, 3. notify waiting pulls
The primary priority queue remains authoritative. `GroupScheduler` is a lazy secondary view built only when grouped work appears: one heap for ready ungrouped jobs, one delayed/TTL heap, and one immutable priority/FIFO lane per group. Lower BullMQ Pro group priorities run first; equal-priority entries keep their durable admission order. These indexes let a rate- or concurrency-blocked group remain parked while other groups continue round-robin, avoiding queue-head blocking and temporary pop/reinsert cycles. Primary and secondary membership change together under the same synchronous shard lock. :::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [Data Structures](/architecture/data-structures/) - The 4-ary MinHeap and skip list behind these queues - [Application Layer](/architecture/application-layer/) - Operations that drive these state transitions ::: --- # Application Layer: Operations, Stalls & DLQ bunqueue application layer: PUSH/PULL/ACK operations, stall detection, dependency resolution, DLQ management, and job lifecycle flows. URL: https://bunqueue.dev/architecture/application-layer/
architecture · application layer

Use cases in the application layer.

The application layer orchestrates all queue operations, coordinating between the client layer and domain layer: PUSH, PULL and ACK flows, stall detection, dependency resolution, and background tasks.

The server selects one application manager at startup. `QueueManager` owns the synchronous memory/SQLite path shown in the diagrams below; `PostgresQueueManager` exposes the same handler-facing operations but commits state through database transactions and refreshes a bounded compatibility projection. See [Storage backends](/guide/databases/) for the multi-broker path. ## Module Structure ``` src/application/ ├── queueManager.ts # Central orchestrator ├── postgresQueueManager.ts # PostgreSQL manager facade ├── postgres-queue-manager/ # Transactional operations and local projection ├── operations/ # PUSH, PULL, ACK, Query ├── backgroundTasks.ts # Task orchestration ├── cleanupTasks.ts # Memory cleanup, orphan removal ├── clientTracking.ts # Client connection tracking ├── contextFactory.ts # Context creation helpers ├── dependencyProcessor.ts # Dependency resolution ├── dlqManager.ts # Dead letter queue ├── eventsManager.ts # Event pub/sub ├── jobLogsManager.ts # Job logs management ├── latencyTracker.ts # Operation latency percentiles ├── lockManager.ts # Lock management ├── lockOperations.ts # Lock acquire/release ops ├── metricsExporter.ts # Prometheus metrics export ├── monitoringChecks.ts # Periodic health checks ├── stallDetection.ts # Stall detection ├── statsManager.ts # Queue statistics ├── taskErrorTracking.ts # Background task circuit breaker ├── throughputTracker.ts # Push/pull/ack rate tracking ├── types.ts # Shared type definitions ├── webhookManager.ts # Webhook notifications └── workerManager.ts # Worker tracking ``` `PostgresQueueManager` replaces the base delivery and lifecycle operations at the same handler boundary. Admissions and terminal transitions commit job state, ownership, results, and durable events together; pulls claim ordered rows with `FOR UPDATE SKIP LOCKED`; ACK/FAIL validates database-clock leases and broker-session tokens. A bounded local projection serves compatibility reads and is repaired from the durable outbox plus polling. The complete transaction, lease, and replay model is documented in the repository reference `docs/features/postgres-multibroker.md`, the [architecture overview](/architecture/), and the user-facing [storage guide](/guide/databases/). ## QueueManager Orchestration
QueueManagermemory / SQLite orchestrator
state
shards[N] paired with shardLocks[N], N auto-detected
processingShards[N] paired with processingLocks[N]
jobIndex Map<id, location>
completedJobs BoundedSet, 50k
jobResults LRU, 10k
customIdMap LRU, 50k
operations
push() operations/push.ts
pull() operations/pull.ts
ack() operations/ack.ts
query operations/queryOperations.ts
managers
DLQManager
EventsManager
WorkerManager
WebhookManager
StatsManager
JobLogsManager
background tasks
cleanup
stall
dependency
dlq
cron
## PUSH Operation Flow
PUSH flowmemory / SQLite push(queue, input)
1. Generate UUIDv7 ID, 2. check customId idempotency customIdMap, if exists return existing job
3. Acquire shard write lock shardIdx = fnv1a(queue) & SHARD_MASK
4. check unique key deduplication
Key available register key, continue
Key exists, strategy replace remove old, insert new
Key exists, strategy extend reset TTL, return existing
Key exists, default return existing
5. check dependencies
All satisfied push to queue
Not satisfied add to waitingDeps, register in dependencyIndex
6. Update jobIndex, 7. persist to configured SQLite buffered or durable; no-op in memory mode, 8. notify waiters wake long poll, 9. broadcast 'pushed' event
## PULL Operation Flow
PULL flowpull(queue, timeoutMs), runs as a loop
1. Acquire shard write lock, 2. queue paused, return null, 3. promote due secondary-index entries
4. select through secondary scheduling indexes
TTL expired drop, try next
Ungrouped ready serve before grouped work
Grouped ready priority/FIFO lane, round-robin cursor, check group limits
No candidate track earliest delayed/rate-window wake-up

The authoritative priority heap and secondary ungrouped, delayed and per-group indexes change under the same synchronous shard lock. A blocked group requires no heap scan or temporary reinsertion.

Job found move to processing shard
No job wait for notification, event-based with timeout, then retry loop
5. Acquire queue and group capacity, 6. remove authoritative job, 7. advance group cursor, 8. create lock token if useLocks enabled, 9. update jobIndex to 'processing', 10. persist/broadcast active, 11. return job with token
## ACK Operation Flow
ACK flowack(jobId, result, token)
1. Verify lock token if provided, on mismatch error: token invalid
2. Remove from processing shard procIdx = fnv1a(jobId) & SHARD_MASK
3. release shard resources
Release unique key
Release active group capacity
Release concurrency slot
4. finalize
Store result in jobResults LRU
Store result in SQLite
Update jobIndex to 'completed'
Add to completedJobs signals deps
5. Add to pendingDepChecks wake dependents, 6. broadcast 'completed' event, 7. trigger webhooks
## Background Tasks
Background task scheduler
Cleanup every 10s
Stall check every 5s
Dependency event-driven, 30s safety fallback
DLQ maintenance every 60s
Lock expire every 5s
Cron precise setTimeout, 60s safety fallback

Processing timeouts use one next-deadline timer keyed by each active job's startedAt + timeout. Far-future timers are safely chunked at the runtime ceiling; failed timeout transitions are logged and retried.

### Stall Detection (Two-Phase)
Stall checkevery 5s, two-phase
phase 1, process previous candidates
For each job in stalledCandidates: still in processing? get stall config
↓ if confirmed stalled
stallCount < maxStalls increment + retry
stallCount >= maxStalls move to DLQ
phase 2, mark new candidates
For each job in processingShards: no heartbeat for > stallInterval 30s, add to stalledCandidates checked next tick

Why two-phase? It prevents false positives from transient delays, like a GC pause or a network hiccup.

### Dependency Resolution
Dependency processorevent-driven, microtask-coalesced
0. On job completion, a flush is scheduled on the next microtask completions from the same tick are coalesced; a 30s interval is only a safety fallback for missed events
1. Collect completedIds from pendingDepChecks set of jobs that completed since last flush
2. For each completedId, look up dependencyIndex[completedId] returns the Set of jobIds waiting for this job
3. Group by shard for efficient locking
4. For each waiting job, check ALL dependencies completed completedJobs.has(depId) for all deps
5. If all satisfied: remove from waitingDeps, unregister from dependencyIndex, push to active queue
### Cleanup Tasks
Cleanupevery 10s
1. Refresh delayed counts in each shard
2. Compact priority queues if stale ratio > 20%, rebuild heap
3. Clean orphaned processing entries jobs stuck > 30min with no heartbeat
4. Clean stale waiting dependencies waiting > 1 hour
5. Clean expired unique keys
6. Clean orphaned job index entries
7. Remove empty queues
## Event Broadcasting
Events manager
Event occurs completed, failed, progress, stalled
broadcast(event)
Notify all subscribers Set-based, O(1) add
Trigger matching webhooks
Wake completion waiters

Event-based waiting, no polling: waitForJobCompletion(jobId, timeout) resolves when the 'completed' event for jobId arrives.

:::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [Domain Layer](/architecture/domain-layer/) - Shards and the state machine these operations mutate - [TCP Protocol](/architecture/tcp-protocol/) - How operations arrive over the wire - [Persistence](/architecture/persistence/) - Where these operations are durably recorded ::: --- # TCP Protocol Architecture: Wire Format & Pipelining bunqueue TCP protocol deep dive: MessagePack wire format, pipelining, connection pooling, and binary command architecture. URL: https://bunqueue.dev/architecture/tcp-protocol/
architecture · tcp protocol

Frames on the wire.

bunqueue uses a high-performance binary protocol over TCP with MessagePack serialization and optional pipelining. This page covers the wire format, connection lifecycle, and command set.

## Wire Format Each message is a **length-prefixed MessagePack frame**: | Bytes | Content | | ----- | ----------------------------------------- | | 0-3 | Frame length (4 bytes, big-endian uint32) | | 4-N | MessagePack payload | **Maximum frame size:** 64 MB Both directions preserve frame ordering under socket backpressure. Bun's TCP write is unbuffered and may accept only a prefix, so the reference client and server retain the exact unwritten tail and place later frames behind it until `drain`. Each queue belongs to one physical socket and is discarded on close; commands are never blindly replayed after reconnect because the broker may already have applied them. ## TCP Pipelining Pipelining allows multiple commands to be sent without waiting for responses, dramatically improving throughput. ### Without Pipelining (Sequential) ``` Client Server │── PUSH job1 ────────────>│ │<── { ok, id } ───────────│ wait ~1ms │── PUSH job2 ────────────>│ │<── { ok, id } ───────────│ wait ~1ms │── PUSH job3 ────────────>│ │<── { ok, id } ───────────│ wait ~1ms Total: 3 round-trips ≈ 3ms Throughput: ~1,000 ops/sec (one command per 1ms round-trip) ``` ### With Pipelining (Parallel) ``` Client Server │── PUSH job1 (reqId:1) ──>│ │── PUSH job2 (reqId:2) ──>│ no wait │── PUSH job3 (reqId:3) ──>│ no wait │<── { ok, reqId:1 } ──────│ │<── { ok, reqId:2 } ──────│ │<── { ok, reqId:3 } ──────│ Total: 1 round-trip ≈ 1ms Throughput: ~3,000 ops/sec (three commands per 1ms round-trip) ``` **Result: 3x faster in this illustrative 1 ms example.** Real throughput depends on latency, connection count, batching, durability, database size, and storage backend. The [current benchmark page](/guide/benchmarks/) publishes measured workloads instead of treating this round-trip sketch as a capacity claim. ### How Pipelining Works 1. **Client sends commands** with unique `reqId` identifiers 2. **Server processes in parallel** (up to 50 concurrent per connection) 3. **Responses include `reqId`** for matching (may arrive out of order) 4. **Client matches responses** using a `Map` ### Configuration ```typescript const queue = new Queue('my-queue', { connection: { host: 'localhost', port: 6789, pipelining: true, // Enable pipelining (default: true) maxInFlight: 100, // Max concurrent commands (default: 100) poolSize: 32, // Connection pool size commandTimeout: 30000, // Timeout per command (ms) pingInterval: 30000, // Health-check ping interval (ms, 0 disables) maxCommandTimeouts: 3, // Consecutive command timeouts → reconnect (0 disables) }, }); ``` | Option | Default | Description | | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | | `pipelining` | `true` | Enable TCP pipelining | | `maxInFlight` | `100` | Max commands in flight per connection | | `poolSize` | `4` | Number of TCP connections | | `commandTimeout` | `30000` | Command timeout (ms) | | `pingInterval` | `30000` | Health-check ping interval (ms, `0` disables) | | `maxCommandTimeouts` | `3` | Consecutive command timeouts (no intervening success) before the link is concluded dead and reconnect is forced (`0` disables) | ## Protocol Version Negotiation On connect, client and server negotiate protocol version: ```typescript // Client → Server { cmd: 'Hello', protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'] } // Server → Client { ok: true, protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'], server: 'bunqueue', version: '2.x.y' } ``` Protocol v3 supports pipelining and a separate top-level job `name`, leaving the user-owned `data` value unchanged. The server accepts clients that omit `Hello`; legacy job input without top-level `name` is decoded only at the inbound protocol boundary. ## Connection Lifecycle **States:** 1. **DISCONNECTED** → Initial state 2. **CONNECTING** → Socket.connect() in progress 3. **CONNECTED** → Ready for commands 4. **RECONNECTING** → Auto-reconnect with backoff **Connect sequence:** 1. TCP socket connect 2. Send `Hello` (protocol negotiation) 3. Send `Auth` (if token configured) 4. Start ping timer 5. Ready for commands **Reconnect strategy:** - Base delay: 100ms - Max delay: 30s - Backoff: exponential (2x each attempt) - Jitter: additive, up to +30% of the computed delay **Dead-link detection (half-open sockets):** A socket can go **half-open**, the peer vanishes with no FIN/RST (suspended host, NAT/load-balancer silently dropping an idle connection). Writes still succeed and no `close`/`error` event fires, so the client must detect it actively. Two independent signals conclude the link is dead and trigger `forceReconnect()`: 1. **Health-check ping**, after `maxPingFailures` (3) consecutive failed pings. 2. **Command timeouts**, after `maxCommandTimeouts` (3) consecutive command timeouts with no intervening success. This is the path that recovers a worker whose `PULL`s keep timing out, without waiting for the slower ping cycle (and it works even when the ping is disabled). The counter resets on any successful response. On detection the socket is torn down, all in-flight commands are rejected immediately (`Connection lost`) so callers unblock at once, and the reconnect/backoff loop above re-establishes a fresh connection. `SO_KEEPALIVE` is also enabled so the OS can surface a dead peer on its own rather than lingering until `tcp_retries2` (~15 min). For _fast_ recovery, lower `pingInterval` / `commandTimeout`, e.g. `{ pingInterval: 10000, commandTimeout: 5000 }` recovers in ~tens of seconds vs ~120s on defaults (each default timeout is 30s, so timeout-based detection is inherently coarse). ## Authentication If `AUTH_TOKENS` is configured on the server, clients must authenticate: ```typescript // Client → Server { cmd: 'Auth', token: 'your-secret-token' } // Server → Client { ok: true } // or { ok: false, error: 'Invalid token' } ``` Token comparison uses constant-time algorithm to prevent timing attacks. ## Commands Reference ### Core Commands | Command | Description | Request | Response | | ------- | -------------- | ----------------------------------------- | ----------------------------------------------- | | `PUSH` | Add single job | `{ cmd, queue, data, priority?, delay? }` | `{ ok, id }` | | `PUSHB` | Add batch | `{ cmd, queue, jobs }` | `{ ok, ids }` | | `PULL` | Get single job | `{ cmd, queue, timeout?, group? }` | `{ ok, job, token? }` | | `PULLB` | Get batch | `{ cmd, queue, count, timeout?, group? }` | `{ ok, jobs, tokens? }` | | `ACK` | Complete job | `{ cmd, id, result?, token? }` | `{ ok, data?: { applied, reason } }` | | `ACKB` | Complete batch | `{ cmd, ids, results?, tokens? }` | `{ ok, data?: { ignoredIds, ignoredIndices } }` | | `FAIL` | Fail job | `{ cmd, id, error?, token? }` | `{ ok, data?: { applied, reason } }` | ### Query Commands | Command | Description | | ---------------------- | -------------------------- | | `GetJob` | Get job by ID | | `GetJobByCustomId` | Get job by custom ID | | `GetState` | Get job state | | `GetResult` | Get job result | | `GetJobs` | List jobs with filters | | `GetJobCounts` | Queue statistics | | `GetCountsPerPriority` | Counts grouped by priority | | `GetProgress` | Get job progress | | `Count` | Count jobs in queue | ### Job Group Commands `GetGroupJobsCount`, `GetGroupsJobsCount`, and `GetGroupActiveCount` expose server-authoritative depth. `Set/Get/RemoveGroupRateLimit`, `GetGroupRateLimitTtl`, and `Set/Get/RemoveGroupConcurrency` manage local group overrides. Results are wrapped in `data`; see the [wire reference](/api/tcp/) for exact shapes. ### Control Commands | Command | Description | | --------------- | --------------------------- | | `Pause` | Stop processing queue | | `Resume` | Resume processing | | `IsPaused` | Check if queue is paused | | `Drain` | Remove waiting jobs | | `Obliterate` | Delete queue completely | | `Clean` | Remove old jobs | | `Cancel` | Cancel pending job | | `Promote` | Move delayed job to waiting | | `MoveToDelayed` | Move job to delayed state | | `Progress` | Update job progress | | `ListQueues` | List all queues | ### DLQ Commands | Command | Description | | ---------------- | -------------------- | | `Dlq` | List DLQ entries | | `RetryDlq` | Retry failed jobs | | `RetryCompleted` | Retry completed jobs | | `PurgeDlq` | Clear DLQ | ### Cron Commands | Command | Description | | ------------ | --------------------- | | `Cron` | Add scheduled job | | `CronGet` | Get one scheduled job | | `CronDelete` | Remove scheduled job | | `CronList` | List all cron jobs | ### Monitoring Commands | Command | Description | | ------------------ | --------------------------- | | `Stats` | Server statistics | | `Metrics` | Queue metrics | | `Prometheus` | Prometheus format | | `Ping` | Health check | | `Heartbeat` | Worker heartbeat | | `JobHeartbeat` | Per-job heartbeat | | `AddLog` | Add job log entry | | `GetLogs` | Get job logs | | `RegisterWorker` | Register worker with server | | `UnregisterWorker` | Unregister worker | | `ListWorkers` | List registered workers | ## Connection Pool The client maintains a pool of TCP connections for load balancing: ```typescript // Default: 4 connections, configurable via poolSize const pool = new TcpConnectionPool({ host: 'localhost', port: 6789, poolSize: 32, // 32 connections for high throughput }); ``` **Selection strategy:** Round-robin, preferring connected sockets. **Features:** - Automatic reconnection - Health tracking (latency, errors) - Shared pools (reference counted) ## Client Disconnect Handling When a client disconnects, the server: 1. Identifies all jobs owned by client 2. Releases job locks (returns to queue) 3. Cleans up client tracking Jobs with active locks are automatically requeued for other workers. ## Validation Limits | Parameter | Limit | | ------------ | ------------------------------------ | | Queue name | Max 256 chars, alphanumeric + `_-.:` | | Job data | Max 10 MB JSON | | Priority | -1,000,000 to +1,000,000 | | Delay | 0 to 365 days | | Timeout | 0 to 24 hours | | Max attempts | 1 to 1,000 | | Backoff | 0 to 24 hours | | TTL | 0 to 365 days | ## HTTP Endpoints bunqueue also exposes an HTTP API on port 6790: | Endpoint | Method | Description | | --------------------- | ------ | --------------------- | | `/health` | GET | Health + memory stats | | `/healthz` | GET | Kubernetes liveness | | `/ready` | GET | Kubernetes readiness | | `/prometheus` | GET | Prometheus metrics | | `/stats` | GET | JSON statistics | | `/queues/:queue/jobs` | POST | Add job | | `/queues/:queue/jobs` | GET | Pull job | | `/jobs/:id` | GET | Get job | | `/jobs/:id/ack` | POST | Acknowledge | | `/jobs/:id/fail` | POST | Fail | | `/ws` | GET | WebSocket | | `/events` | GET | Server-Sent Events | :::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [TCP Protocol Reference](/api/tcp/) - Command-by-command wire spec - [Persistence](/architecture/persistence/) - MessagePack serialization shared with storage - [Client SDK Architecture](/architecture/client-sdk/) - The pool that speaks this protocol ::: --- # SQLite Persistence: WAL, Write Buffer, S3 Backups bunqueue persistence layer: SQLite WAL mode config, write buffering, read-through cache, S3 backup flows, and durability guarantees. URL: https://bunqueue.dev/architecture/persistence/
architecture · persistence

WAL mode and write buffers.

bunqueue uses SQLite with WAL mode for persistence, optimized for high-throughput job processing: batched writes, crash recovery, and S3 backups.

This page is intentionally specific to the memory/SQLite engine. Standalone servers configured with PostgreSQL bypass `SqliteStorage` and `WriteBuffer`; they use Bun's native `SQL` pool and database-authoritative transactions. See [Storage backends](/guide/databases/) for that architecture and its backup, durability, and failover boundaries. ## SQLite Configuration
SQLite pragmasset at startup
journal_mode = WAL Write-Ahead Logging
synchronous = NORMAL balanced safety/performance
cache_size = -64000 64MB in-memory cache
temp_store = MEMORY in-memory temp tables
mmap_size = 268435456 256MB memory-mapped I/O
page_size = 4096 4KB pages
busy_timeout = 5000 wait up to 5s on lock contention
## Write Buffer Architecture
Write bufferjob arrives
Add to buffer max 100 jobs
Buffer full 100 jobs
Timer fires 10ms
Batch insert INSERT INTO jobs VALUES (...), (...), (...)

Prepared statement cache (1-100 rows), single transaction, 50-100x faster than individual inserts.

## Buffered vs Durable Mode
Write modesper-job choice
Buffered, default up to 10 ms loss; 186,384 jobs/s median for the published public on-disk Embedded addBulk workload
Durable, opt-in per job immediate write; 60,835 ops/s median for the published sequential Embedded add workload

Usage: queue.add('job', data, { durable: true })

The two medians above describe different workloads. They are capacity evidence, not a direct buffered-versus-durable ratio; see [Engineering Benchmarks](/guide/benchmarks/) for distributions and TCP results. ## Database Schema
TablesSQLite schema
jobs, 30 columns
id TEXT PRIMARY KEY, UUIDv7
queue TEXT
data BLOB, MessagePack
priority INTEGER
state TEXT
run_at INTEGER
attempts INTEGER
... 23 more fields
indexes
(queue, state) PULL queries
(queue, created_at, id) stable unfiltered pagination
(queue, state, created_at, id) stable state pagination
(run_at) delayed job scheduler
(queue, unique_key) deduplication
(custom_id) idempotency
(parent_id) parent job lookup
(state, started_at) stall detection
(group_id) group operations
(queue, state, priority, run_at) priority pull
(completed_at DESC) completed-job recovery ordering
job_results job_id TEXT PRIMARY KEY, result BLOB MessagePack, completed_at INTEGER
dlq id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, queue TEXT, entry BLOB full DlqEntry MessagePack, entered_at INTEGER
cron_jobs name TEXT PRIMARY KEY, queue TEXT, data BLOB, schedule TEXT, repeat_every INTEGER, priority INTEGER, next_run INTEGER, executions INTEGER, max_limit INTEGER, timezone TEXT, unique_key TEXT, dedup BLOB, skip_missed_on_restart INTEGER, skip_if_no_worker INTEGER, prevent_overlap INTEGER, job_options BLOB
queue_state name TEXT PRIMARY KEY, paused/rate/concurrency fields plus stall_enabled, stall_interval, max_stalls, stall_grace_period; persists queue controls and custom stall policy across restarts
## Crash Recovery Flow
Startup recoverycrash recovery on boot, batches of 10,000 rows
1. Recover active jobs restore custom stall policy first, then read repeated 10k batches from offset zero; each interrupted job persists stallCount++ and attempts++. Below both maxStalls and maxAttempts it is requeued with backoff; reaching either bound persists exactly one DLQ entry. Cron-spawned preventOverlap jobs are dropped, the scheduler recreates them
2. Load pending jobs state waiting/delayed: jobs with unmet dependencies go to waitingDeps, the rest to their shard queue; jobIndex, customId and uniqueKey mappings restored
3. Load DLQ entries restore to in-memory DLQ shards, populate jobIndex
4. Restore queue state paused flag, rate limit and concurrency limit per queue; the stall policy snapshot was already applied before active recovery
5. Load completed jobs up to the 50k in-memory cap, for clean() and stats
6. Load cron jobs populate cron scheduler heap; past next_run is recalculated forward when skipMissedOnRestart is set
## S3 Backup Flow
S3 backupscheduled: every 6 hours, configurable
backup
1. Flush pending WriteBuffer abort if any write remains
2. SQLite VACUUM INTO WAL-safe standalone snapshot
3. PRAGMA integrity_check
4. Gzip + SHA256 uncompressed bytes
5. Upload metadata sidecar {unique-key}.meta.json
6. Publish gzip payload commit point
7. Cleanup old backups keep N most recent
restore
1. Download backup file from S3
2. Load metadata sidecar
3. Decompress, verify size + SHA256
4. Validate temp SQLite candidate
5. Quarantine WAL/SHM, atomically rename

Stop the server before restore. Current compressed backups require metadata; legacy no-metadata restores are uncompressed only.

## Flush on Failure
Error recoveryflush() fails
Re-buffer failed jobs double-buffered swap: the failed flush buffer is merged back, nothing is dropped
Retry with exponential backoff 100ms initial, 30s max, up to 10 retries; regular flushing pauses during backoff
After max retries critical-error callback fires with the affected jobs, so they can be surfaced instead of silently lost
on shutdown
Final flush of remaining buffer, wait for completion before exit
## Serialization
MessagePackwhy MessagePack instead of JSON?
2-3x faster encoding/decoding
Smaller payload size
Binary data support
used for
Job data BLOB
DLQ entry BLOB
TCP protocol payloads
Job results storage
:::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [Data Structures](/architecture/data-structures/) - In-memory structures backed by this store - [TCP Protocol](/architecture/tcp-protocol/) - MessagePack payloads shared with the wire format - [S3 Backup](/guide/backup/) - Backing up the SQLite file this layer owns ::: --- # Memory/SQLite Data Structures: MinHeap, Skip List & LRU Data structures powering bunqueue's memory/SQLite engine: 4-ary MinHeap, skip lists, LRU cache, FNV-1a hashing, and read-write locks. URL: https://bunqueue.dev/architecture/data-structures/
architecture · data structures

Heaps, skip lists, LRUs.

The memory/SQLite engine uses a 4-ary MinHeap, skip lists, LRU caches, FNV-1a hashing, and read-write locks. PostgreSQL uses database rows, indexes, and locks for authoritative ordering and ownership.

This page covers the base memory/SQLite engine and its in-process compatibility caches. PostgreSQL mode keeps only bounded projections needed by the shared API; its authoritative claim, scheduling, limit, and lifecycle structures are the database tables and indexes described in [Storage backends](/guide/databases/). ## Overview | Structure | Use Case | Complexity | |-----------|----------|------------| | 4-ary MinHeap | Priority queue, cron scheduling, delayed-job tracking | O(log₄ n) | | Skip List | Queue-local temporal indexing, cleanup range queries | O(log q) | | LRU Cache | Job results, custom IDs | O(1) | | Hash (FNV-1a) | Sharding, distribution | O(len) | ## 4-ary MinHeap Used for priority queues, cron scheduling, and delayed-job tracking (TemporalManager keeps delayed jobs in a MinHeap ordered by runAt for O(k) refresh).
Why 4-ary vs binary?cache locality
Binary heap height log₂(n) = 16 levels for 65k items, 2 children per node, more memory indirections
4-ary heap height log₄(n) = 8 levels for 65k items, 4 children per node, children fit in cache line (64 bytes), fewer cache misses

Trade-off: 4 comparisons per level vs 2. Win: better cache locality outweighs extra comparisons.

### Heap with Lazy Deletion
Generation trackinglazy deletion
Each entry has a generation number { jobId, priority, runAt, generation: 42n }
Index maps jobId → { job, generation }
REMOVE delete from index O(1), heap entry becomes stale
POP loop: peek, check generation match, mismatch: skip stale entry, match: return job
COMPACT, stale ratio > 20% filter valid entries, rebuild heap O(n)
The delayed-job heap uses a different lazy-removal check: a `Map` is the live source of truth. When no delayed jobs remain, the heap is cleared immediately. Otherwise it is rebuilt in O(n) once there are at least 256 stale entries and stale entries are at least as numerous as live entries. This bounds retained heap memory after cancellation or promotion churn. ## Skip List Used for queue-local temporal indexes (jobs ordered by `createdAt`, then job ID) and efficient range queries during cleanup. Each queue owns a separate skip list, while a direct job-ID map points to the corresponding entries for logarithmic removal. Delayed jobs are tracked separately in a MinHeap, not here.
Skip list structuresorted list with express lanes
level 3
50
level 2
25
50
75
level 1
10
25
30
50
60
75
level 0
10
25
30
50
60
75

Properties: probabilistic level assignment (p=0.5), expected height O(log n), simpler than balanced trees, good cache locality (sequential links).

### Range Queries
Range querygetOldJobs(threshold, limit)
1. Select the queue-local index O(1)
2. Walk forward at level 0 O(k)
3. Collect while createdAt < threshold

Total: O(log q + k), where q is the number of indexed jobs in that queue and k is the number returned. Removal uses the job-ID map plus a queue-local skip-list delete: O(log q).

## LRU Cache Used for job results, custom ID mapping, and logs.
Doubly-linked LRUMap<Key, Node> plus doubly-linked list
A HEAD, most recent
B
C
D TAIL, LRU
GET(key) find in map O(1), move to head, O(1) pointer updates
SET(key, value) if at capacity remove tail, evict LRU, add new node at head

All operations: O(1).

### Memory Bounds
Bounded collectionsmax size, eviction
completedJobs 50,000, FIFO batch (10%)
jobResults 10,000, LRU
jobLogs 10,000, LRU
customIdMap 50,000, LRU
DLQ per queue 10,000, FIFO

BoundedSet (FIFO): no recency tracking (faster), batch eviction removes 10% when full, amortized cost across many operations.

## Hash Function (FNV-1a) Used for sharding and distribution in the memory/SQLite engine.
FNV-1a hashalgorithm
hash = FNV_OFFSET 0x811c9dc5
for each byte: hash = hash XOR byte, hash = hash * FNV_PRIME 0x01000193
return hash unsigned 32-bit
Fast ~10-15 CPU cycles per character
Good distribution
Deterministic
Non-cryptographic speed over security
### Sharding
Shard selectionshardIndex = fnv1a(queueName) & SHARD_MASK
SHARD_COUNT auto-detected from CPU cores, power of 2, SHARD_MASK = SHARD_COUNT - 1
4 cores SHARD_COUNT=4, SHARD_MASK=0x03, binary 11
10 cores SHARD_COUNT=16, SHARD_MASK=0x0f, binary 1111
20 cores SHARD_COUNT=32, SHARD_MASK=0x1f, binary 11111
64+ cores SHARD_COUNT=64, capped

Why bitwise AND? 3-5x faster than modulo, requires power-of-2 shard count, hash & SHARD_MASK is equivalent to hash % SHARD_COUNT.

## Lock Structures ### RWLock (Read-Write Lock)
Read-write lockRWLock
Multiple concurrent readers
Single exclusive writer
Writer priority prevents starvation
Direct writer handoff reserve writer = true before resolving the oldest waiter; late arrivals cannot barge
timeout cancellation
Settle once, decrement live writer count once, skip cancelled heads, then dispatch writers or the compatible reader cohort
## Complexity Summary | Operation | Structure | Time | |-----------|-----------|------| | Push job (memory/SQLite) | 4-ary heap | O(log₄ n) | | Pop job (memory/SQLite) | 4-ary heap | O(log₄ n) | | Find job | Index map | O(1) | | Remove job | Lazy deletion | O(1) | | Get result | LRU map | O(1) | | Shard lookup | Hash + AND | O(len) | | Range query | Queue-local skip list | O(log q + k) | | Remove temporal entry | Job-ID map + queue skip list | O(log q) | | Lock acquire | RWLock | O(1) uncontested | :::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [Domain Layer](/architecture/domain-layer/) - Where these structures hold jobs and priorities - [Cron Scheduler](/architecture/cron-scheduler/) - Uses a MinHeap for time-ordered runs ::: --- # Cron Scheduler: SQLite Heap and PostgreSQL Coordination bunqueue cron internals: a MinHeap scheduler for memory/SQLite, transactional PostgreSQL multi-broker execution, Bun-native parsing, and timezones. URL: https://bunqueue.dev/architecture/cron-scheduler/
architecture · cron scheduler

One schedule. One winning broker.

Memory/SQLite uses an event-driven MinHeap with lazy deletion. PostgreSQL stores shared schedules in the database and lets competing brokers lock each due row transactionally. Both use Bun's native cron parser and the same public API.

## Memory/SQLite System Overview
CronSchedulerheap plus generation map
cronJobs Map<name, {cron, generation}>, O(1) lookup
cronHeap MinHeap<CronHeapEntry>, O(k log n)
generation number, lazy deletion
tick(), event-driven
1. Pop due crons from heap nextRun <= now
2. Check stale generation mismatch: skip
3. Check execution limit auto-remove if reached
4. Persist to SQLite when configured before pushing, prevents duplicates
5. Push job to queue
6. Re-insert with same generation
7. scheduleNext() arm a precise setTimeout for the next due cron

The scheduler is event-driven: a precise setTimeout wakes it exactly when the next cron is due, rearmed after every add/remove/load/tick. A 60s setInterval acts only as a safety fallback against timer drift or missed events.

## PostgreSQL Multi-Broker Execution PostgreSQL mode does not run one independent heap per broker. Every schedule is stored in `bunqueue_crons` under the deployment namespace. On each maintenance pass, a broker opens one transaction, samples the database clock, and selects due rows in `(next_run, name)` order with `FOR UPDATE SKIP LOCKED`. The winning transaction checks the shared worker registry, admits the spawned job, advances `executions` and `next_run`, and commits those changes together. Other brokers skip the locked row, so one slot cannot fire twice. Startup reconciliation is elected under a namespace advisory lock: the oldest live broker session handles missed-slot policy, preventing simultaneous startup from making every broker skip the same schedule. Due cron rows are found by the configured PostgreSQL maintenance polling interval. After a committed admission, the shared event path can use `LISTEN/NOTIFY` to wake other brokers and workers; durable rows remain authoritative after a missed notification or connection reset. Before either SQLite recovery or PostgreSQL broker registration mutates state, startup validates every persisted calendar definition against Bun's supported grammar. An unsupported pre-2.9 Croner extension fails startup with an actionable name and schedule; the collection is never partially reconciled. ## Core Data Structures ### CronJob Interface ```typescript interface CronJob { name: string; // Unique identifier jobName: string; // Name of each spawned Job queue: string; // Target queue data: unknown; // Job payload schedule: string | null; // Cron expression (5-6 fields) repeatEvery: number | null; // Interval in ms priority: number; // Job priority timezone: string | null; // IANA timezone nextRun: number; // Next execution timestamp (absolute ms) executions: number; // Current execution count maxLimit: number | null; // Max executions (null = unlimited) uniqueKey: string | null; // Dedup key for spawned jobs dedup: CronDedup | null; // Dedup options (ttl, extend, replace) skipMissedOnRestart: boolean; // Skip missed runs on restart skipIfNoWorker: boolean; // Skip push if no worker registered preventOverlap: boolean; // Auto uniqueKey `cron:` (default: true) jobOptions: CronJobOptions | null; // Per-spawned-job retry/cleanup policy } ``` Source: `src/domain/types/cron.ts`. ### Generation-Based Lazy Deletion Instead of O(n) heap removals, we use generation numbers: ```typescript interface CronHeapEntry { cron: CronJob; generation: number; // Unique per entry } // Remove operation: O(1) remove(name: string): boolean { this.cronJobs.delete(name); // Just delete from map // Heap entry becomes "stale" - skipped in tick() return true; } // In tick(): skip stale entries const current = this.cronJobs.get(entry.cron.name); if (current?.generation !== entry.generation) { continue; // Stale entry, skip } ``` Source: `src/infrastructure/scheduler/cron/runtime.ts` and `src/infrastructure/scheduler/cron/execution.ts`; the public façade remains `src/infrastructure/scheduler/cronScheduler.ts`. ## Scheduling Modes ### Cron Expressions Supports Bun's standard five-field cron syntax, a compatible six-field form with leading seconds, and shortcuts: | Shortcut | Expression | Description | | ---------- | ----------- | --------------------- | | `@yearly` | `0 0 1 1 *` | Once per year | | `@monthly` | `0 0 1 * *` | First day of month | | `@weekly` | `0 0 * * 0` | Sunday at midnight | | `@daily` | `0 0 * * *` | Every day at midnight | | `@hourly` | `0 * * * *` | Every hour | ### Timezone Support Uses Bun's native parser for timezone-aware scheduling: ```typescript const nextDate = Bun.cron.parse('0 2 * * *', fromTime, { tz: 'Europe/Rome' }); ``` The optional leading seconds field is handled by bunqueue before the remaining five fields are passed to Bun. It accepts values `0-59` with `*`, lists, ranges, and steps. Seven-field years and `L`, `W`, `#`, `+`, and `?` are not supported. ### Interval-Based (RepeatEvery) Simple offset-based scheduling: ```typescript function getNextIntervalRun(intervalMs: number, lastRun: number): number { return lastRun + intervalMs; } ``` Interval crons run at a fixed rate: the next run is anchored to the slot the fire was scheduled for, not to wall-clock time at execution, so a slow or late fire does not cumulatively drift the schedule forward. ## Memory/SQLite Execution Flow
Execution flowtick() fires when the precise timer (or the 60s safety fallback) wakes
while heap.peek().nextRun <= now
entry = heap.pop() O(log n)
Stale? gen mismatch yes: skip, continue
↓ no
At execution limit? yes: auto-remove
↓ no
1. Calculate new executions and nextRun, 2. persist to SQLite FIRST when configured, 3. update in-memory state, 4. fire the job (push to queue), 5. re-insert to heap
scheduleNext() arm a precise setTimeout for the next non-stale heap entry
**Fire guards** (checked just before pushing the job): - `skipIfNoWorker`: the push is skipped when no worker is registered for the target queue. - Overlap detection: the fire is skipped if the last fire for this cron happened within 80 percent of the interval window. - `preventOverlap` (default true): the spawned job gets an automatic `uniqueKey` of `cron:`, so a new job is deduplicated while the previous one is still active. ## Persistence & Recovery ### SQLite Schema ```sql CREATE TABLE cron_jobs ( name TEXT PRIMARY KEY, queue TEXT NOT NULL, job_name TEXT, data BLOB NOT NULL, -- MessagePack schedule TEXT, repeat_every INTEGER, priority INTEGER NOT NULL DEFAULT 0, next_run INTEGER NOT NULL, -- absolute ms timestamp executions INTEGER NOT NULL DEFAULT 0, max_limit INTEGER, timezone TEXT, unique_key TEXT, dedup BLOB, -- MessagePack skip_missed_on_restart INTEGER NOT NULL DEFAULT 0, skip_if_no_worker INTEGER NOT NULL DEFAULT 0, prevent_overlap INTEGER NOT NULL DEFAULT 1, job_options BLOB -- MessagePack ); ``` ### Memory/SQLite Recovery on Startup ```typescript // In QueueManager initialization this.cronScheduler.load(this.storage.loadCronJobs()); // O(n) heapify ``` During `load()`, any cron whose persisted `nextRun` is in the past has it recalculated forward (and re-persisted) when `skipMissedOnRestart` or `skipIfNoWorker` is set, so missed runs are skipped instead of firing immediately on boot. ### PostgreSQL Storage PostgreSQL stores the encoded `CronJob`, `next_run`, `executions`, and `max_limit` in `bunqueue_crons`. Scheduler upsert/remove/list operations and due execution use the same database row, so every broker sees one shared identity. Job admission and schedule advancement commit in the same transaction; unlike the local persist-first path, there is no persisted-advance/job-admission gap. ## Memory/SQLite Error Handling ### Persist-First Execution State is persisted before the job is pushed, so a crash between the two steps can never produce a duplicate fire: ```typescript // 1. Calculate new state BEFORE anything else const newExecutions = cron.executions + 1; const newNextRun = calculateNextRun(cron); // interval crons anchor to the scheduled slot // 2. Persist FIRST; on failure: do NOT push, re-insert entry, retry on next tick this.persistCron(cron.name, newExecutions, newNextRun); // 3. Update in-memory state AFTER successful persist cron.executions = newExecutions; cron.nextRun = newNextRun; // 4. NOW push the job (state already persisted, safe from duplicates). // If the push fails, the job is lost but the schedule stays consistent; // a `cron:missed` dashboard event is emitted and the next run proceeds. await this.fireCronJob(cron, now); ``` ## Memory/SQLite Performance Characteristics | Operation | Complexity | Notes | | ---------------- | -------------- | ------------------------------------------------ | | `add()` | O(log n) | Heap push + map insert, rearms the precise timer | | `remove()` | **O(1)** | Lazy deletion via generation | | `tick()` | O(k log n) | k = due crons | | `scheduleNext()` | O(1) amortized | Peek heap, pop stale entries, arm setTimeout | | `list()` | O(n) | Iterate map | | `load()` | O(n) | Heapify from array | ## Memory/SQLite Timing Model There is no configurable polling interval. The scheduler is event-driven: ```typescript // Precise timer, chunked at the runtime's signed 32-bit timeout ceiling const delay = Math.min(Math.max(0, nextEntry.cron.nextRun - Date.now()), 2_147_483_647); this.nextTimer = setTimeout(() => void this.tick(), delay); // Safety fallback: catches timer drift and missed events const SAFETY_FALLBACK_MS = 60_000; this.safetyInterval = setInterval(() => void this.tick(), SAFETY_FALLBACK_MS); ``` The legacy `checkIntervalMs` config option is still accepted for backward compatibility but is deprecated and ignored. Schedules farther than about 24.8 days keep their original absolute `nextRun` in memory and SQLite. The bounded timer wakes at the ceiling, the normal due guard observes that the cron is still in the future, and the scheduler rearms for the remaining duration. This avoids Bun's overflow fallback to a 1ms timer without consuming an execution, persisting an intermediate timestamp, or creating a job early. ## Usage Example The client SDK exposes the scheduler through `Queue.upsertJobScheduler()` (embedded mode calls `QueueManager.addCron()` directly; TCP mode sends the `Cron` command): The returned `SchedulerInfo.next` is authoritative in both modes: embedded uses the `CronJob.nextRun` returned by the scheduler, while TCP reads the nested `cron.nextRun` returned by the broker. It therefore matches an immediate `getJobScheduler()` lookup for both interval and pattern schedules. ```typescript // Add a cron job (2 AM daily, Rome time, at most 365 runs) await queue.upsertJobScheduler( 'daily-cleanup', { pattern: '0 2 * * *', timezone: 'Europe/Rome', limit: 365 }, { data: { type: 'cleanup' } } ); // Add an interval-based job (every minute) await queue.upsertJobScheduler('health-check', { every: 60_000 }, { data: { check: 'ping' } }); // Remove a scheduler await queue.removeJobScheduler('daily-cleanup'); // Inspect a scheduler const info = await queue.getJobScheduler('health-check'); ``` :::tip[Related] - [Architecture Overview](/architecture/) - Full component map - [Data Structures](/architecture/data-structures/) - Skip list behind time-ordered scheduling - [Persistence](/architecture/persistence/) - Where schedulers survive restarts - [Storage Backends](/guide/databases/) - PostgreSQL cron coordination and failover boundaries - [Cron Jobs Guide](/guide/cron/) - Using schedulers from the client ::: --- # bunqueue FAQ: Bun Job Queue Questions Answered Common questions about bunqueue answered: SQLite, PostgreSQL 15–18 multi-broker mode, embedded vs server, performance, retries, scaling, backups, and migration. URL: https://bunqueue.dev/faq/ import { Tabs, TabItem } from '@astrojs/starlight/components';
reference · faq

Asked, answered.

One-paragraph answers on storage, modes, performance, retries, scaling and migration. If your question is not here, GitHub Discussions is the next stop.

Short answers to the questions people actually ask. Each answer links to the page that owns the topic. ## Basics ### What is bunqueue? bunqueue is a job queue for Bun: you push jobs (units of work, like "send this email") onto a named queue, and workers pull and process them with retries, priorities, and scheduling. It uses memory/SQLite by default and offers an optional PostgreSQL 15–18 server backend for multiple brokers; 18.6 is recommended. Its API is compatible with BullMQ, so migration is mostly an import change. Start with the [quickstart](/guide/quickstart/). ### Why SQLite instead of Redis? For the default deployment it means one less server. There is nothing to install or monitor, and backup is one safe SQLite snapshot (or the built-in [S3 backup](/guide/backup/)). Bun's native SQLite bindings keep the hot path synchronous; see the [comparison](/guide/comparison/). When brokers themselves must scale horizontally, select the separate PostgreSQL backend instead. ### Does it run on Node.js? The server is Bun-only (`bun:sqlite`, `Bun.serve`, `Bun.listen` do not exist in Node), but your producers and workers run anywhere: official SDKs exist for [TypeScript (Node.js, Deno, Bun, Cloudflare Workers), Python, PHP, Go, Rust and Elixir](/guide/sdks/), all speaking the same TCP protocol. `bunqueue-client` builds its default API from the same TypeScript sources as `bunqueue/client`, including Queue, Worker, QueueEvents, QueueGroup, FlowProducer, and Simple Mode. On Node.js and Deno, use `embedded: false`, nested `connection` options, and the `Async` queue methods for remote reads and mutations. The historical SDK API remains available explicitly from `bunqueue-client/legacy`. ### What are the requirements? Bun 1.4.0 or newer (enforced via the package `engines` field) on macOS, Linux, or Windows via WSL. An SSD helps write throughput. Install steps are on the [installation page](/guide/installation/). ### How heavy is the install? One runtime dependency: `msgpackr` for the MessagePack wire and persistence formats. Cron parsing uses Bun's native `Bun.cron.parse()`. The MCP SDK is an optional peer dependency: only install `@modelcontextprotocol/sdk` if you use the [MCP server](/guide/mcp/), the launcher tells you if it is missing. Queue and Worker users never need it. ## Modes and storage ### What is the difference between embedded and server mode? Embedded mode (`new Queue('q', { embedded: true })`) runs the queue inside your process with memory/SQLite. Server mode runs `bunqueue start` and clients connect over TCP. A server may use memory/SQLite with one broker or PostgreSQL 15–18 with multiple brokers. Pick one backend per deployment; the same SQLite file must never be opened by two processes at once. ### Where is my data stored? Nowhere, unless you say so. Without a data path or PostgreSQL URL, jobs are held in memory and lost on restart. Set `dataPath` in embedded mode, a SQLite data-path variable/flag for one server, or `BUNQUEUE_POSTGRES_URL` plus the PostgreSQL driver for a multi-broker server: ```bash BUNQUEUE_DATA_PATH=./data/production.db bunqueue start # or, in standalone multi-broker mode BUNQUEUE_STORAGE_DRIVER=postgres \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:secret@postgres:5432/bunqueue' \ bunqueue start ``` ### How does persistence work? Jobs are written to SQLite in WAL mode (write-ahead logging, a journal that lets reads and writes overlap). By default writes are buffered for up to 10 ms and flushed in batches. If that process-crash window is unacceptable for a job, add it with `{ durable: true }` to commit before `add()` returns. This closes bunqueue's application buffer; power-loss durability still depends on SQLite's host, filesystem, and storage. Current native evidence measures public on-disk `addBulk` and sequential durable adds as separate workloads; see [benchmarks](/guide/benchmarks/) rather than comparing one rounded headline. In PostgreSQL mode, PostgreSQL is authoritative and each mutation is transactional. Competing brokers claim with row locks and `SKIP LOCKED`; opaque database-clock leases fence stale ACK/FAIL attempts. SQLite buffering and its published performance figures do not apply to that backend. ### Can I use PostgreSQL or MySQL instead? PostgreSQL **15–18 is supported in standalone server mode** and allows multiple active bunqueue brokers to share one database/namespace; 18.6 is pinned and recommended. SQLite remains the default and the only persistent embedded backend. MySQL is not supported. See [storage backends](/guide/databases/) for configuration and boundaries. ### How do I back up and restore? For SQLite, enable the built-in S3 backup (`S3_BACKUP_ENABLED=1` plus bucket and credentials) or take a safe SQLite snapshot. Restore with `bunqueue backup list` and `bunqueue backup restore --force`. For PostgreSQL, use your database provider's backup and point-in-time-recovery tooling; bunqueue's S3 command does not snapshot PostgreSQL. Full SQLite guide: [backup](/guide/backup/). ## Performance ### How fast is it? The current native campaign measured a **186,384 jobs/s median** for public on-disk Embedded `addBulk`, **158,779 jobs/s** for TCP `PUSHB`, and **60,835 / 27,191 ops/s** for sequential durable Embedded/TCP adds. These are different workloads, not one interchangeable throughput number. Dated distributions, integrity totals, and methodology live on the [benchmarks page](/guide/benchmarks/). ### How do I get more throughput? Three knobs, in order of impact: raise worker `concurrency` (parallel jobs per worker), batch your inserts with `queue.addBulk(jobs)` (one round-trip for many jobs), and raise the worker's `batchSize` so it pulls and acknowledges jobs in batches. In TCP mode, concurrent `queue.add()` calls are also auto-batched for you by default. See [worker options](/guide/worker/). ## Jobs and retries ### How does deduplication work? Pass a `jobId`. Adding the same `jobId` twice returns the existing job instead of creating a duplicate, same behavior as BullMQ. This makes webhook handlers and restart-recovery code safe to re-run: ```typescript await queue.add('charge', data, { jobId: `order-${orderId}` }); // idempotent ``` ```typescript await queue.add('charge', data, { jobId: `order-${orderId}` }); // idempotent ``` ```python queue.add("charge", data, job_id=f"order-{order_id}") # idempotent ``` ```php $queue->add('charge', $data, ['jobId' => "order-{$orderId}"]); // idempotent ``` ```go queue.Add("charge", data, bunqueue.JobOptions{"jobId": "order-" + orderID}) // idempotent ``` ```rust // idempotent queue.add("charge", data, JobOptions { job_id: Some(format!("order-{order_id}")), ..Default::default() })?; ``` ```elixir # idempotent {:ok, _job} = Bunqueue.Queue.add(queue, "charge", data, jobId: "order-#{order_id}") ``` ### How do retries and backoff work? `attempts` sets the maximum tries, `backoff` sets the wait between them. A plain number means exponential backoff (waits roughly double each retry: ~2s, ~4s, ~8s with a 1000ms base); the object form `{ type: 'fixed' | 'exponential', delay }` matches BullMQ. All delays get automatic jitter, a small random spread so thousands of failed jobs do not retry in the same instant, and are capped at 1 hour by default. ```typescript await queue.add('task', data, { attempts: 5, backoff: 1000 }); ``` ```typescript await queue.add('task', data, { attempts: 5, backoff: 1000 }); ``` ```python queue.add("task", data, attempts=5, backoff=1000) ``` ```php $queue->add('task', $data, ['attempts' => 5, 'backoff' => 1000]); ``` ```go queue.Add("task", data, bunqueue.JobOptions{"attempts": 5, "backoff": 1000}) ``` ```rust use bunqueue_client::{Backoff, JobOptions}; queue.add("task", data, JobOptions { attempts: Some(5), backoff: Some(Backoff::Milliseconds(1000)), ..Default::default() })?; ``` ```elixir {:ok, _job} = Bunqueue.Queue.add(queue, "task", data, attempts: 5, backoff: 1000) ``` ### What happens when a worker crashes mid-job? Workers send heartbeats (periodic "still alive" pings). If one goes silent, stall detection marks its active jobs as stalled and requeues them. A job that stalls too many times goes to the dead letter queue instead of looping forever. See [stall detection](/guide/stall-detection/). ### What is the dead letter queue? The DLQ is the holding area for jobs that exhausted their retries, threw an unrecoverable error, or stalled too many times. Nothing is silently dropped: you can inspect entries, retry them (`queue.retryDlq()`), or purge them. It also supports auto-retry and expiration policies. See [DLQ](/guide/dlq/). ### Can I control processing order? FIFO (first in, first out) is the default for jobs of equal priority, so ordered processing needs no options. Use `{ priority: 10 }` to jump the line (higher runs sooner), or `{ lifo: true }` for the newest-first LIFO partition. At equal numeric priority, LIFO jobs run ahead of FIFO jobs; FIFO entries retain oldest-first order within their partition. ## Scaling and production ### Can I run multiple workers? Yes. Any number of worker processes can connect over TCP and share queues. With SQLite the queue broker stays single while workers multiply. With PostgreSQL 15–18, both workers and bunqueue broker processes can multiply; 18.6 is recommended. ### Does bunqueue support multiple brokers or high availability? Yes, when server mode uses PostgreSQL 15–18; 18.6 is recommended. Brokers share authoritative rows, leases, limits, cron/worker state, and durable events; a stale broker cannot finalize a lease recovered elsewhere. Database availability, routing, backup, and failover remain operator responsibilities. SQLite mode deliberately stays single-broker. This is not a multi-region consensus layer; see [storage backends](/guide/databases/) and [deployment](/guide/deployment/). ### Is bunqueue production-ready? Yes. Retries with backoff, stall detection, a dead letter queue, rate limiting, native TLS, and Prometheus metrics are built in. SQLite mode also includes S3 snapshots; PostgreSQL deployments use their database's backup and PITR tooling. The [production guide](/guide/production/) covers deployment, sizing, and operations. ## Migration ### Can I migrate from BullMQ? Yes, and it is usually small: change the import to `bunqueue/client`, delete the Redis connection, add `embedded: true` (or a TCP `connection`). `Queue`, `Worker`, `QueueEvents`, `FlowProducer`, job options, and events keep the same shapes. The real differences (backoff shorthand, `repeat.cron` renamed to `pattern`, boolean-only `removeOnComplete`) are listed in the [migration guide](/guide/migration/). ### Can I migrate from other queues? There is no importer, but the job format is plain JSON. Export your jobs and bulk-insert them: ```typescript await queue.addBulk( oldJobs.map((j) => ({ name: j.type, data: j.payload, opts: { priority: j.priority }, })) ); ``` ## Workflows ### What is the Workflow Engine, and when do I use it over FlowProducer? `FlowProducer` builds parent-child job trees: fan out children, run the parent when they finish. The Workflow Engine is a step-by-step orchestrator for business processes: ordered steps with per-step retry, conditional branching, parallel blocks, loops, saga compensation (automatic rollback of completed steps when a later one fails), and `waitFor` signals for human approval. Use FlowProducer for job dependency graphs, Workflow for processes with rollback, branching, or human decisions. See [workflow](/guide/workflow/) and [flows](/guide/flow/). ### Do workflows survive restarts? Yes, when the `Engine` has a persistent `dataPath`. Its execution state (current step, step results, received signals) lives in that local SQLite file, so `recover()` can resume it after a restart. Without `dataPath`, the workflow store is in memory and does not survive process exit. The Engine can enqueue work through embedded, TCP, or PostgreSQL-backed bunqueue servers, but PostgreSQL does not replace this separate local workflow store. ## Troubleshooting ### "SQLITE_BUSY: database is locked" Two processes are writing to the same SQLite file. Run exactly one embedded instance per file, or switch to server mode so all processes go through one server. More cases in [troubleshooting](/troubleshooting/). ### "Job not found" The job was already removed: it completed with `removeOnComplete: true`, failed with `removeOnFail: true`, or was deleted manually. Completed-job records are also bounded in memory, so very old results eventually age out. ### High memory usage Usually accumulation: completed jobs kept around, a growing DLQ, or oversized job payloads (keep payloads small, store big blobs elsewhere and pass a reference). Add jobs with `removeOnComplete: true`, purge the DLQ periodically (`queue.purgeDlq()`), and clean old jobs with `queue.clean(3600000, 1000)` (grace period in ms, max jobs to remove). ## Contributing ### How can I contribute? Report bugs on [GitHub Issues](https://github.com/egeominotti/bunqueue/issues), propose features in [Discussions](https://github.com/egeominotti/bunqueue/discussions), or send a PR. To develop locally: clone the repo, `bun install`, `bun test`. :::tip[Related] - [Troubleshooting](/troubleshooting/), debug common issues - [Quickstart](/guide/quickstart/), first queue in two minutes - [bunqueue vs BullMQ](/guide/comparison/), features and honest benchmarks ::: --- # Troubleshooting bunqueue: Common Issues & Fixes Fix common bunqueue problems: SQLite database locks, memory leaks, connection timeouts, job processing failures, and embedded mode issues. URL: https://bunqueue.dev/troubleshooting/ import { Tabs, TabItem } from '@astrojs/starlight/components';
reference · troubleshooting

Troubleshooting, cause and fix.

Symptoms, causes and fixes for the issues people actually hit: SQLite locks, embedded mode misconfiguration, stuck jobs, half-open connections and backup failures.

## Installation Issues ### "bunqueue is Bun-only and requires the Bun runtime" Running under Node.js throws: > bunqueue is Bun-only and requires the Bun runtime (https://bun.sh). Node.js is not supported: install Bun and run your program with `bun`. bunqueue only works with Bun (v1.4.0+), not Node.js, run your program with `bun`, not `node`. ```bash # Check if Bun is installed bun --version # Install Bun if needed curl -fsSL https://bun.sh/install | bash ``` ### Permission errors on install ```bash # Try with sudo (not recommended) sudo bun add bunqueue # Better: fix npm permissions mkdir ~/.bun chown -R $(whoami) ~/.bun ``` ## Database Issues ### "SQLITE_BUSY: database is locked" Multiple processes trying to write simultaneously. **Solutions:** 1. Use WAL mode (default in bunqueue) 2. Ensure only one server instance per database file 3. Use server mode for multi-process access ```bash # Check for multiple processes lsof ./data/queue.db # Kill stale processes pkill -f bunqueue ``` ### "SQLITE_CORRUPT: database disk image is malformed" Database corruption, usually from crash during write. **Solutions:** 1. Restore from S3 backup 2. Delete and recreate database (data loss) ```bash # Restore from backup bunqueue backup list bunqueue backup restore --force # Or recreate (loses data) rm ./data/queue.db* bunqueue start ``` ### Database file keeps growing SQLite doesn't automatically reclaim space. ```bash # Vacuum the database (run when server is stopped) sqlite3 ./data/queue.db "VACUUM;" # Enable auto-vacuum (before creating database) sqlite3 ./data/queue.db "PRAGMA auto_vacuum = INCREMENTAL;" ``` ## Embedded Mode Issues ### "Command timeout" error ``` error: Command timeout queue: "my-queue", context: "pull" ``` This error means your Worker is trying to connect to a TCP server instead of using embedded mode. **Solution:** Add `embedded: true` to **both** Queue and Worker: ```typescript // WRONG - Worker defaults to TCP mode const queue = new Queue('tasks', { embedded: true }); const worker = new Worker('tasks', processor); // Missing embedded: true! // CORRECT - Both have embedded: true const queue = new Queue('tasks', { embedded: true }); const worker = new Worker('tasks', processor, { embedded: true }); ``` ### SQLite database not created The database is only created when a data path is configured. **Solution (embedded mode):** ```typescript import { Queue, QueueEvents, Worker, shutdownManager } from 'bunqueue/client'; // Pass dataPath directly const queue = new Queue('tasks', { embedded: true, dataPath: './data/bunqueue.db' }); const worker = new Worker('tasks', processor, { embedded: true, dataPath: './data/bunqueue.db' }); ``` **Solution (server mode):** Use a [configuration file](/guide/configuration/) or set `BUNQUEUE_DATA_PATH`: ```bash BUNQUEUE_DATA_PATH=./data/bunqueue.db bunqueue start ``` :::note Without `dataPath` or `BUNQUEUE_DATA_PATH`, bunqueue runs in-memory (no persistence across restarts). ::: ### Jobs not persisted across restarts In embedded mode the shared QueueManager is a process-wide singleton initialized by the **first** embedded `Queue`, `Worker`, or `QueueEvents`. If that client has no explicit or environment data path, the manager starts in-memory. Clients that omit `dataPath` then join the active manager. A later explicit path is never ignored. If it identifies a different database, construction throws an `Embedded QueueManager dataPath conflict` error before any job can be accepted with the wrong durability. Relative, absolute, and symlink spellings of the same existing database are accepted. Common pitfall: setting `process.env.DATA_PATH` at the top of `main.ts` and then importing a module that constructs a Queue, Worker, or QueueEvents. ESM imports are hoisted, so the module and its constructors run **before** the assignment. **Solution 1 (recommended):** pass `dataPath` directly in the constructor options, no env var needed: ```typescript const queue = new Queue('tasks', { embedded: true, dataPath: './data/bunqueue.db' }); const worker = new Worker('tasks', processor, { embedded: true, dataPath: './data/bunqueue.db' }); const events = new QueueEvents('tasks', { embedded: true, dataPath: './data/bunqueue.db' }); ``` **Solution 2:** set the env var before the process starts: ```bash BUNQUEUE_DATA_PATH=./data/bunqueue.db bun run main.ts ``` To switch databases in one process, close every embedded client and call `shutdownManager()` before constructing the next one. Use separate processes or TCP brokers when databases must remain active concurrently. ## Job Processing Issues ### Jobs stuck in "active" state Worker crashed while processing. **Solutions:** 1. Enable stall detection 2. Restart workers ```typescript queue.setStallConfig({ enabled: true, stallInterval: 30000, maxStalls: 3, }); ``` ### Jobs not being processed **Check these:** 1. Is the queue paused? 2. Is there a worker for this queue? 3. Is rate limiting blocking jobs? ```typescript // Check if paused const isPaused = await queue.isPausedAsync(); // Check counts const counts = await queue.getJobCountsAsync(); console.log(counts); ``` ```typescript // Check if paused const isPaused = await queue.isPaused(); // Check counts const counts = await queue.getJobCounts(); console.log(counts); ``` ```python # Check if paused is_paused = queue.is_paused() # Check counts counts = queue.get_job_counts() print(counts) ``` ```php // Check if paused $isPaused = $queue->isPaused(); // Check counts $counts = $queue->getJobCounts(); var_dump($counts); ``` ```go // Check if paused paused, _ := queue.IsPaused() // Check counts counts, _ := queue.GetJobCounts() fmt.Println(paused, counts) ``` ```rust // Check if paused let paused = queue.is_paused()?; // Check counts let counts = queue.get_job_counts()?; println!("{paused} {counts:?}"); ``` ```elixir # Check if paused {:ok, paused} = Bunqueue.Queue.is_paused(queue) # Check counts {:ok, counts} = Bunqueue.Queue.get_job_counts(queue) ``` ### "Job is not active" when calling updateProgress Progress can only be updated while the job is **active** (being processed). The server rejects `Progress` for any other state with `Job is not active (current state: ...)`. Typical causes: - Calling `job.updateProgress()` after the processor returned (job already completed) - The job was failed/stalled/cancelled underneath a long-running processor - Updating progress from outside the worker while the job is still waiting Treat it as a signal that you no longer own the job, not as a transient error. ### getJobs() does not show a job I just added In SQLite mode, job listings (`getJobs`, `GetJobs` over TCP) read from SQLite, while non-durable pushes go through a write buffer that flushes about every 10ms. A job added a moment ago can therefore be missing from a listing for up to ~10ms. Use `getJob(id)` / `getState(id)` (which read the in-memory index) for read-after-write checks, or add the job with `durable: true` to bypass the buffer. PostgreSQL admissions are transactional and do not use the SQLite write buffer. The broker that accepts a push refreshes its local PostgreSQL projection before acknowledging it. Other brokers converge through the durable outbox and `LISTEN` wakeups, with polling as the fallback, so a listing sent immediately to a different broker can briefly reflect its previous projection. ### Jobs failing immediately Check the error in failed event: ```typescript worker.on('failed', (job, error) => { console.error('Job failed:', error); console.error('Job data:', job.data); console.error('Attempts:', job.attemptsMade); }); ``` ```typescript worker.on('failed', (job, error) => { console.error('Job failed:', error); console.error('Job data:', job.data); console.error('Attempts:', job.attempts); }); ``` ```python def on_failed(job, err): print("Job failed:", err) print("Job data:", job.data) print("Attempts:", job.attempts) worker.on("failed", on_failed) ``` ```php $worker->on('failed', function ($job, $error) { error_log('Job failed: ' . $error->getMessage()); error_log('Attempts: ' . $job->attemptsMade()); }); ``` ```go worker.On("failed", func(args ...any) { job := args[0].(*bunqueue.Job) err := args[1].(error) log.Printf("job %s failed after %d attempts: %v", job.ID(), job.AttemptsMade(), err) }) ``` ```rust // Rust has no worker event emitter: a failed attempt surfaces where you return it, // and transport-level problems arrive on the connection telemetry callback. let worker = Worker::new( "queue", |job| { process(job.data()).map_err(|e| { eprintln!("job {} failed on attempt {}: {e}", job.id(), job.attempts_made()); ProcessError::retryable(e.to_string()) }) }, WorkerOptions::default(), ); ``` ```elixir # Elixir has no worker event emitter: a failed attempt surfaces where you return it, # and transport-level problems arrive on the connection `:event_handler` callback. worker = Bunqueue.Worker.new("queue", fn job -> case process(job.data) do {:ok, result} -> {:ok, result} {:error, reason} -> IO.puts("job #{job.id} failed on attempt #{job.attempts_made}: #{inspect(reason)}") {:error, reason} end end) ``` ### Memory usage keeps growing **Possible causes:** 1. Jobs not being removed after completion 2. Too many jobs in DLQ 3. Memory leak in processor ```typescript // Enable removeOnComplete await queue.add('task', data, { removeOnComplete: true, }); // Purge old DLQ entries queue.purgeDlq(); // Check for leaks in processor worker.on('completed', () => { console.log('Memory:', process.memoryUsage().heapUsed); }); ``` ## Connection Issues ### "Connection refused" to server Server not running or wrong port. ```bash # Check if server is running ps aux | grep bunqueue # Check listening ports lsof -i :6789 lsof -i :6790 # Start server bunqueue start ``` ### TCP connection drops Network issues or server overload. ```typescript // Add reconnection logic let client = createClient(); client.on('error', async () => { await sleep(1000); client = createClient(); }); ``` ### Worker stalls on a half-open connection (throughput drops to 0) A worker's TCP socket can go **half-open**, the peer vanishes with no FIN/RST (host suspended/hibernated, NAT or load-balancer silently dropping an idle connection). Writes still succeed and no `close` event fires, so the symptom is: every command rejects with `Command timeout`, `consecutiveErrors` climbs, jobs pile up in `waiting` with `active=0`, and throughput sits at 0. bunqueue detects this and reconnects automatically via two signals: the health-check ping (`maxPingFailures`) **and** consecutive command timeouts (`maxCommandTimeouts`, default 3). With default timings (`pingInterval`/`commandTimeout` = 30s) recovery takes up to ~120s. For faster recovery, tighten the detection cadence: ```typescript const worker = new Worker('q', handler, { connection: { host, port, pingInterval: 10_000, // health-check every 10s (0 disables) commandTimeout: 5_000, // fail a command after 5s maxCommandTimeouts: 3, // 3 consecutive timeouts → reconnect (0 disables) }, }); ``` This recovers in ~tens of seconds and works even with the ping disabled. If a *fresh* connection also can't be established (e.g. the server is genuinely unreachable, a firewall dropping inbound SYNs, not just an idle drop), no client can reconnect until connectivity returns; auto-reconnect with infinite attempts resumes on its own once it does. ### Authentication failures ```bash # Check token is set echo $AUTH_TOKENS # Test with curl curl -H "Authorization: Bearer your-token" \ http://localhost:6790/health ``` ## Performance Issues ### Slow job processing **Optimize:** 1. Increase worker concurrency 2. Use batch operations 3. Check database I/O ```typescript // Increase concurrency const worker = new Worker('queue', processor, { concurrency: 20, }); // Use bulk add await queue.addBulk([...jobs]); // Workers batch pulls and acks automatically; tune the batch size const batchWorker = new Worker('queue', processor, { batchSize: 100 }); ``` ```typescript // Increase concurrency const worker = new Worker('queue', processor, { concurrency: 20, }); // Use bulk add await queue.addBulk([...jobs]); // Workers batch pulls automatically; tune the batch size const batchWorker = new Worker('queue', processor, { batchSize: 100 }); ``` ```python # Increase concurrency worker = Worker("queue", process, concurrency=20) # Use bulk add queue.add_bulk(jobs) # Workers batch pulls automatically; tune the batch size batch_worker = Worker("queue", process, batch_size=100) ``` ```php // The PHP worker is sequential by design; scale by running more worker processes // Use bulk add $queue->addBulk($jobs); // Workers batch pulls automatically; tune the batch size $worker = new Worker('queue', $processor, ['batchSize' => 100]); ``` ```go // Increase concurrency worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{Concurrency: 20}) // Use bulk add ids, err := queue.AddBulk(entries) // Workers batch pulls automatically; tune the batch size batchWorker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{BatchSize: 100}) ``` ```rust use bunqueue_client::{Worker, WorkerOptions}; // Increase concurrency let worker = Worker::new("queue", processor, WorkerOptions { concurrency: 20, ..Default::default() }); // Use bulk add let ids = queue.add_bulk(entries)?; // Workers batch pulls automatically; tune the batch size let batch_worker = Worker::new("queue", processor, WorkerOptions { batch_size: 100, ..Default::default() }); ``` ```elixir # Increase concurrency worker = Bunqueue.Worker.new("queue", processor, concurrency: 20) # Use bulk add {:ok, ids} = Bunqueue.Queue.add_bulk(queue, jobs) # Workers batch pulls automatically; tune the batch size worker = Bunqueue.Worker.new("queue", processor, batch_size: 100) ``` ### High latency on pull **Check:** 1. Index on queue table 2. Too many delayed jobs 3. Database on slow disk ```sql -- Check indexes exist .indices jobs -- Check delayed jobs count SELECT COUNT(*) FROM jobs WHERE state = 'delayed'; ``` ### Server CPU at 100% Too many connections or jobs. ```bash # Check connection count bunqueue stats # Reduce polling frequency # Add backoff in clients ``` ## Backup Issues ### S3 backup failing ```bash # Check credentials echo $S3_ACCESS_KEY_ID echo $S3_BUCKET # Test connectivity aws s3 ls s3://$S3_BUCKET/ # Check logs bunqueue backup status ``` ### Restore failing ```bash # List available backups bunqueue backup list # Force restore (overwrites existing) bunqueue backup restore --force ``` ## Sandboxed Worker Issues :::danger[SandboxedWorker is experimental] `SandboxedWorker` depends on [Bun Workers](https://bun.sh/docs/runtime/workers), which are **experimental**. Known issues include memory growth, thread duplication, and segfaults across Bun versions. **For production, use the standard `Worker` instead**, it provides the same API (events, concurrency, heartbeats, retries) without any experimental dependencies. See [Worker vs SandboxedWorker](/guide/worker/sandboxed/#worker-vs-sandboxedworker). ::: ### Segmentation fault when terminating workers If you experience crashes (segfaults) when using `SandboxedWorker`, especially during worker timeout or error handling, this is a **known Bun bug**. **Symptoms:** - `Segmentation fault at address 0xE8` - `Worker has been terminated` errors - Crashes during `worker.terminate()` calls - Unexpected memory growth or thread duplication ([#52](https://github.com/egeominotti/bunqueue/issues/52)) **Solution:** Switch to the standard `Worker` for production workloads: ```typescript // ❌ SandboxedWorker: experimental, may crash const worker = new SandboxedWorker('queue', { processor: './processor.ts', concurrency: 4, }); // ✅ Worker: stable, production-ready, same functionality const worker = new Worker('queue', async (job) => { // same logic from your processor.ts return result; }, { embedded: true, concurrency: 4 }); ``` **If you must use SandboxedWorker:** - Pin your Bun version, behavior varies across releases - Use graceful shutdown (`await worker.stop()`) instead of force termination - Use longer timeout values to avoid frequent terminations - Monitor memory usage closely These issues will be resolved when Bun stabilizes their Worker API. ## Common Error Messages | Error | Cause | Solution | |-------|-------|----------| | `Command timeout` | Worker missing `embedded: true` | Add `embedded: true` to Worker options | | `SQLITE_BUSY` | Database locked | Use single writer | | `SQLITE_FULL` | Disk full | Free disk space | | `ECONNREFUSED` | Server not running | Start server or use embedded mode | | `ETIMEDOUT` | Network issue | Check connectivity | | `Job not found` | Already completed/removed | Check job lifecycle | | `Segmentation fault` | Bun Worker termination bug | Use graceful shutdown, see above | ## Debug Mode The server logs to stdout. Switch to structured JSON logs for easier filtering: ```bash # Server mode (structured logs) LOG_FORMAT=json bunqueue start # Pipe to a file if you want persistent logs LOG_FORMAT=json bunqueue start >> /var/log/bunqueue.log 2>&1 ``` ## Getting Help If these solutions don't help: 1. Check [GitHub Issues](https://github.com/egeominotti/bunqueue/issues) 2. Search [Discussions](https://github.com/egeominotti/bunqueue/discussions) 3. Open a new issue with: - bunqueue version - Bun version - OS and hardware - Error message and stack trace - Minimal reproduction code :::tip[Related Guides] - [Monitoring & Prometheus Metrics](/guide/monitoring/) - Set up monitoring to prevent issues - [FAQ](/faq/) - Frequently asked questions - [Stall Detection & Recovery](/guide/stall-detection/) - Debug stalled jobs ::: --- # bunqueue Changelog: Version History & Release Notes Complete version history for bunqueue Bun job queue. Track new features, bug fixes, performance improvements, and breaking changes. URL: https://bunqueue.dev/changelog/
changelog

Every release, documented.

All notable changes to bunqueue: features, fixes, performance work and breaking changes, newest first.

## [2.9.5] - 2026-09-09 ### Docker distribution variants - Publish Alpine, Debian 13, Debian slim, and distroless variants to Docker Hub and GHCR for Linux amd64 and arm64. Keep unsuffixed version and latest tags on Alpine, with a shared non-root UID and persistent data volume. - Remove the separate Bun runtime and build dependencies from production images. Add the shell-free `healthcheck [url]` HTTP probe for every variant. - Require native image checks for authentication, health, and SQLite recovery before publishing the exact tested images. Allow explicitly requested Docker rebuilds of an existing version through the full CI gates. - Fail closed on Git tag lookup errors and add an explicit, version-checked root npm publication input that waits for all product and image checks. - Pass npm credentials through Bun's native `NPM_CONFIG_TOKEN` variable so the CI authentication check and tarball publication receive the configured token. - Keep Docker Hub tags limited to release versions and variant aliases. Retain build references on GHCR and stop generating timestamp tags. - Explain Docker base-image differences in the README and add a homepage Docker quickstart with variant selection, copyable commands and persistent storage. ### Canonical client parity - Build `bunqueue-client` from the canonical Bun client source, sharing Queue, Worker, Job, FlowProducer, QueueEvents, QueueGroup, Simple Mode, groups, processor batches, options, errors, events, and return contracts. Preserve the historical SDK API at the explicit `/legacy` entry. - Block source/artifact drift and public declaration differences during the SDK build. Run shared native contracts, differential generated histories, and real package scenarios in Bun, Node, Deno, and Workers. - Keep TCP clients from initializing local embedded storage during DLQ reads and asynchronous QueueGroup discovery. - Preserve embedded shard selection under container CPU quotas, reject unreviewed Bun runtime access in portable builds, and verify strict NodeNext declarations plus sandboxed processors across the supported runtimes. - Make archive cutoff regressions deterministic on Linux while retaining mutation coverage of the inclusive timestamp boundary. - Audit the introductory, Queue, and Worker guides; correct token-bound transitions, bulk-admission semantics, option references, SDK examples, and empty table headings. ### CI/CD - Add standalone Windows arm64 and Linux musl x64/arm64 release binaries, completing the eight primary Bun targets. Require all eight compressed assets before publishing the release and include their SHA-256 checksums. - Publish release images to Docker Hub at `egeominotti/bunqueue` alongside GHCR, with matching version, latest, and variant tags for amd64 and arm64. - Update the README and installation/deployment guides with Docker Hub commands, the eight standalone downloads, and runtime and persistence requirements. - Pin Bun to 1.4.2 across CI, SDK workflows, release images, and disposable test images; align deployment examples and the release-gate regression check. ### Queue and SDK performance - Flow creation now batches its telemetry writes after the atomic graph commit, preserving per-node routing and event order while removing repeated storage transaction overhead. - Workflow-engine control jobs now use `removeOnComplete` by default, so completed internal orchestration jobs do not consume the shared completed-job retention budget. User workflow jobs and workflow state remain unchanged. - The TypeScript and Python network SDKs now wake saturated pull loops as soon as a job settles. In the native 20,000-job workload, median Worker time fell by 48.3% and 76.2% respectively without changing ACK/FAIL, heartbeat, lease, concurrency, or shutdown semantics. ### PostgreSQL performance - Batched worker heartbeats now renew every valid fenced lease in one transaction and apply successful versions locally; invalid fences share one repair query instead of creating per-job transactions and projection reloads. Pre-write generation tickets prevent delayed heartbeat or batch-ACK responses from overwriting a newer completed, removed, retried, or re-leased projection. - PostgreSQL event retention now consolidates exact transaction-private deltas into per-queue state and deletes only the oldest excess rows, avoiding both a retained-window index scan and shared counter locks on event writers. - Dependency-free `PUSHB` validation no longer builds three full compatibility snapshot views, and dashboard queue summaries aggregate job states in one snapshot pass instead of once per queue. - PostgreSQL schema version 21 adds event-retention state, transaction-private deltas, and guarded statement-level insert/delete triggers. Its schema guard now distinguishes real immediate primary keys from same-name unique indexes and atomically rebuilds malformed derived retention state. Upgrade every broker in a cluster together; memory and SQLite schemas and behavior are unchanged. ### Documentation - Redesigned the homepage around the free MIT-licensed server, supported client runtimes, and separate server/embedded setup paths with explicit connection addresses. Introduction and installation now explain why the engine uses Bun while network clients keep their own runtime. - Refreshed documentation typography, sidebar and table-of-contents contrast, breadcrumbs, Markdown source links, responsive headers and mobile spacing. Wide tables now preserve their semantics inside keyboard-scrollable regions. - Fixed custom-hero skip-link targets and the homepage's duplicate H1, while keeping existing documentation routes, tab synchronization and search. - Added payload types to the Deno and embedded homepage examples, with a regression that compiles the published TypeScript snippets against both APIs. The Deno command now grants the worker's required hostname permission, and JavaScript/TypeScript workers log connection errors. - Corrected the SDK guide's protocol description: v3 keeps job names separate from user payloads, including scalar, array and null payloads. - Fixed a hosting rule that blocked the current API reference from indexing. Current API pages now receive canonical URLs and distinct metadata and are included in the sitemap; historical versions remain excluded. Social metadata and the homepage cover now reflect the free server and supported client runtimes. - Added a dedicated [bunqueue 2.9.4 performance comparison](/guide/version-performance-2-9-4/) with a native, integrity-checked 11-version Embedded and TCP SQLite lifecycle campaign, methodology, resource results, interpretation guidance, and explicit limitations. ## [2.9.4] - 2026-09-03 > **Deep-profiled queue hot paths and TCP ACK latency.** This change set removes > the quadratic completion-evidence eviction path, stops the in-memory telemetry > journal from retaining event payload graphs, reuses SQLite telemetry statements > and exact retention counts, and prevents low-concurrency TCP workers from waiting > for the 50 ms ACK fallback on every completion wave. The implementation was > driven by Bun 1.4.0 CPU and heap profiles and preserves the existing wire format, > persistence schema, scheduling rules, and public configuration defaults. ### Optimization summary | Area | Previous hot-path cost | Optimization | Resulting behavior | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | TCP Worker ACK batching | A worker could wait for the 50 ms fallback whenever configured capacity exceeded the outcomes that could actually reach the pending batch. | Track an event-driven frontier of pending ACKs, unqueued started generations, and immediately startable scalar buffer entries, capped by `batchSize`. | Full waves still coalesce, while partial/final/native-batch/rate- or group-limited cohorts flush as soon as every reachable outcome is buffered. | | Completion evidence | Once the recent-completion cap was full, repeatedly restarting a `Set` iterator over deleted historical slots made sustained eviction effectively quadratic. | Track recent completion order with a head index and per-occurrence tokens, with bounded stale-slot compaction. | Exact one-at-a-time FIFO eviction is amortized O(1), including delete, pin, unpin, hydration, clear, and same-ID reuse paths. | | In-memory event retention | Every retained lifecycle event kept its payload object graph alive and front-spliced arrays during trimming. | Retain only an exact per-queue event count; subscribers still receive the original synchronous event stream. | `trimEvents()` keeps its count/removal contract without retaining payloads, and empty-queue cleanup releases the count while preserving cumulative metrics. | | SQLite telemetry setup | Scalar telemetry writes repeatedly prepared the same SQL and executed retention SQL even before a queue reached its cap. | Introduce a storage-lifetime telemetry store with cached statements, reusable transactions, and exact committed event counts. | Retention deletes run only on overflow, remove exactly the excess oldest rows, and zero event retention skips journal inserts while terminal metrics remain active. | | SQLite telemetry lifecycle | Trim, clear, and queue deletion could invalidate any cached retention knowledge. | Refresh counts after explicit trim/count operations and invalidate them after clear or queue destruction, only after the surrounding transaction commits. | Cached counts remain aligned with durable rows across restart, trim, clear, obliterate, rollback, and later reuse of the same queue name. | | Profiling workflow | CPU time, JavaScript retention, native allocator high-water, and profiled wall time could be conflated. | Document separate native baseline, CPU, V8 heap, Markdown heap, forced-GC, Bun JSC, process-memory, queue-memory, and mimalloc evidence. | Future investigations can distinguish CPU self/total time, retained JS objects, and native allocator behavior without treating profiled timing or RSS alone as benchmark/leak proof. | ### Native performance evidence All timings below were collected natively on an Apple M1 Max running Bun 1.4.0. Benchmark samples used fresh processes and state; profiled runs were used only for attribution. Results describe these workloads, not a universal throughput guarantee. | Workload | Before | After | Improvement | | ------------------------------------------------------------ | -----------------------------------------: | ---------------------------------------: | ----------------------------------------------: | | TCP Worker, 60 trivial jobs, `concurrency=1`, `batchSize=10` | 19.2795 jobs/s; 3,112.113 ms | 2,710.8484 jobs/s; 22.133 ms | 140.61x throughput; 99.29% less elapsed time | | One-million-job in-memory completion phase | 46,356 ms | 2,691 ms | 94.2% less time; 17.2x faster | | One-million-job full in-memory lifecycle | 48,949 ms; 20,429 jobs/s | 4,335 ms; 230,681 jobs/s | 91.1% less time; 11.3x throughput | | Completion-tracker churn, 300,000 IDs with a 50,000 cap | 8,418.918 ms | 94.179 ms | 89.4x faster | | In-memory event workload, 180,000 events across 20 queues | 17,746,708 retained bytes; 263,580 objects | 3,201,858 retained bytes; 23,649 objects | 82.0% fewer retained bytes; 91.0% fewer objects | | SQLite scalar lifecycle, 3,000 push/pull/ACK operations | 2,788.07 ms | 1,549.35 ms | 44.4% less total time | The final event-driven frontier's five-process median was 2,710.8484 jobs/s; the clean baseline's three-process median was 19.2795 jobs/s. A fresh 1,000-job capacity matrix produced exact `ACKB` widths of 1, 2, 4, and 10 at the matching worker concurrency; a configured width of 2 remained 2 at concurrency 4, confirming that the frontier does not enlarge user-configured batches. The SQLite telemetry work also improved representative workflow throughput while leaving workflow state transitions unchanged: | Workflow scenario | Embedded before | Embedded after | TCP before | TCP after | | ----------------- | --------------: | -------------: | ---------: | ------------: | | Linear | 298/s | 399/s (+33.9%) | 531/s | 565/s (+6.4%) | | Parallel | 275/s | 338/s (+22.9%) | 430/s | 464/s (+7.9%) | | Compensation | 259/s | 314/s (+21.2%) | 434/s | 441/s (+1.6%) | | Signal | 238/s | 284/s (+19.3%) | 427/s | 439/s (+2.8%) | ### TCP ACK correctness and compatibility | Contract | Evidence preserved by the implementation | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Wire compatibility | `ACKB`, request/response framing, MessagePack encoding, lock tokens, result ordering, and retry commands are unchanged. | | Configured batch size | The reachable frontier is a ceiling only: the effective threshold can shrink but never exceeds `batchSize`. | | Native batch processing | A sealed native batch contributes its exact started members, so full and partial batches coalesce without using the configured maximum as a guess. | | Scalar buffer | Only current deliveries that can start within concurrency, rate, and simulated per-group capacity contribute to the pending threshold. | | Outcome phases | A delivery generation moves atomically from unqueued to pending; failure/manual transitions retire it, and ACKs already assigned to a flush cannot inflate a later batch. | | Dynamic controls | Concurrency reduction, pause, runtime rate limiting, and close immediately re-evaluate pending ACKs against the reduced frontier. | | Failure handling | In-flight flushes remain tracked, retry limits and delays are unchanged, and `close()` continues to await pending/in-flight acknowledgements. | | Embedded workers | Embedded ACKs remain direct and do not use the TCP-only capacity ceiling. | | Half-open recovery | Workers continue to surface transient transport errors through the required `error` listener while the connection health path reconnects and resumes throughput. | ### Telemetry and completion invariants | Invariant | Regression coverage | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Recent completion evidence evicts in exact FIFO order | Covers cap overflow plus pin, delete, re-add, unpin, hydrate, clear, and queue-owner deletion. | | A stale order slot cannot evict a newer occurrence of the same job ID | Per-occurrence tokens are checked before eviction and rewritten during compaction. | | Event retention never exceeds the configured SQLite cap | Counts are loaded at startup, inserts are counted transactionally, and only the precise overflow is deleted oldest-first. | | Failed SQLite writes cannot advance the cache | Count updates are applied only after the database transaction returns successfully. | | Explicit trim, telemetry clear, and queue deletion remain exact | Each path refreshes or invalidates its count and is exercised across subsequent writes. | | `maxQueueEvents=0` does not disable terminal metrics | Event rows are skipped, while completed/failed metric grouping and cumulative metadata still run. | | Empty-queue cleanup does not erase cumulative metrics | Only the transient in-memory retention count is released; `obliterate` remains the operation that clears telemetry history. | ### Regression tests added - Added real TCP protocol coverage for low-concurrency ACK flushing, concurrent coalescing, final scalar cohorts, runtime concurrency reduction, full and partial native batches, rate-limited admission, group-blocked and independent groups, and mixed successful/failed native-batch members. - Added hot-path regression coverage that rejects `Set` iterator restarts during completion eviction and proves FIFO behavior across every stale-slot path. - Added SQLite regression coverage proving storage-lifetime statement reuse and exact cached counts across restart, overflow, trim, clear, queue deletion, and reuse. - Added an in-memory cleanup regression proving that transient event retention is released without deleting completed metrics. - Hardened the half-open Worker recovery test with the required EventEmitter `error` listener now that ACKs can expose the expected transient timeout before reconnection more quickly. ### Validation | Gate | Result | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Full disposable-container sandbox | Passed: 8,564 unit tests, 69/69 TCP suites with 499 assertions, and 43/43 embedded suites with 342 tests; zero failures. | | Asynchronous lifecycle command model | Passed: 11 tests and 84,144 assertions against a real TCP broker and SQLite. | | Repeated new TCP ACK regressions | Passed: 200/200 across 20 repetitions of all ten frontier scenarios. | | Focused Worker, ACK, durability, and half-open campaigns | Passed: 107/107 with no job loss, duplicate ACK, ordering error, or unrecovered connection. | | Static verification | TypeScript typecheck, Oxlint/Oxfmt project checks, and `git diff --check` passed. | The final parallel sandbox ran on Bun 1.4.0 in three disposable, network-isolated containers and reported no resource anomalies: | Suite | Duration | Peak RAM | Start -> end RAM | CPU avg / p95 / peak | PID peak | Runner verdict | | -------- | -------: | --------: | -----------------: | ----------------------: | -------: | -------------- | | Unit | 7.96 min | 1.83 GiB | 201.0 -> 272.9 MiB | 65.3% / 230.3% / 692.5% | 132 | No anomalies | | TCP | 8.99 min | 166.1 MiB | 99.7 -> 134.3 MiB | 9.1% / 26.8% / 71.8% | 61 | No anomalies | | Embedded | 4.11 min | 42.8 MiB | 27.3 -> 42.7 MiB | 4.1% / 19.2% / 36.0% | 40 | No anomalies | Container resource growth is still only an investigation signal, not proof of a JavaScript leak. Focused forced-GC heap profiles independently showed that the event-payload graph was removed and did not show retained JavaScript growth; peak RSS remains tracked separately from heap retention. ### Deliberately deferred Deep profiling also identified further opportunities, but they are not part of this change set and their behavior is unchanged: | Candidate | Observed cost | Why it remains separate | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Atomic `removeOnComplete` completion evidence | Durable removal remained roughly 4.2-4.7x more expensive than retaining completed jobs in the isolated ACK benchmark. | Requires a new bounded multi-row transaction while preserving pin, sequence, pruning, rollback, and crash-recovery semantics. | | Further SQLite telemetry coalescing | Disabling telemetry still materially reduces scalar persistence time after statement caching. | Changes write timing/durability and needs an explicit shutdown and failure contract. | | Immediate temporal-index removal | A one-million-job run retained temporal entries until background cleanup even though jobs had completed. | Must update dequeue, retry, terminal, and recovery invariants together. | | Narrow `WorkflowStore.update()` writes | Full workflow persistence remained the largest workflow-specific SQLite caller. | Requires state-specific patches without changing signal, compensation, or recovery ordering. | | Ordered job views and maintained counters | `getJobs()`, `getStats()`, and queue summaries still scale with total in-memory cardinality. | Every lifecycle transition must update new views/counters exactly once and remain model-checked. | | MessagePack replacement | `msgpackr` was measurable on the TCP client but was not the dominant broker or persistence cost. | A replacement must preserve or version the wire format and be benchmarked independently on client and broker. | ## [2.9.3] - 2026-09-02 > **SQLite safety and queue-control release.** Completed history remains > reachable after hot-cache eviction, cleanup and statistics use durable > authority, and long schema upgrades are observable, bounded, and resumable. > This release also completes BullMQ Pro-compatible job groups and native batch > processing across embedded, TCP, and PostgreSQL runtimes. ### Upgrade notes - Back up SQLite before upgrading. Schema 37 is applied before TCP/HTTP bind; legacy payload rewrites are restart-safe and resume from their last committed checkpoint, but a database with committed 2.9.3 migration batches must not be opened by an older binary. Roll forward, or restore the pre-upgrade database and binary together. - `maxCompletedJobs` remains the hot-memory/recovery cap; it is not a disk retention policy. Configure `completedRetentionMs` when automatic expiry is desired. Deleted pages are reused by SQLite, but shrinking an already large file still requires an offline `VACUUM` with sufficient temporary space. - PostgreSQL group support advances its schema to version 20. Upgrade all brokers in a cluster together; older brokers reject the newer schema instead of serving with mixed scheduling semantics. ### Documentation - Audited the 12 server/operations guide pages and re-audited the Worker pages against the source, correcting: `/health` returning HTTP 503 with the `storage` object only when degraded; the config-file `timeouts.worker`, `timeouts.lock`, and `webhooks` keys documented as currently ignored (env vars `WORKER_TIMEOUT_MS`, `LOCK_TIMEOUT_MS`, `WEBHOOK_MAX_RETRIES`, `WEBHOOK_RETRY_DELAY_MS` are the working knobs); the JSON log example rewritten to the real `{timestamp, level, component, message, data?}` shape; the `/stats` field list (no `failed` key; `memory`/`collections` are top-level); `skipLockRenewal` suppressing the whole per-job heartbeat (stall freshness included, even with `useLocks: false`); SandboxedWorker `pollInterval` paced only when no idle thread exists (pulls use a fixed 1s long-poll); stall detection floor corrected to ~35–40s; the PHP worker leasing its full `batchSize` up front and processing sequentially; `cancelJob` returning `false` for pulled-but-not-started jobs; and the Worker overview stating `attempts` as total executions. - Audited the 24 Queue and Worker guide pages against the source and corrected every claim that contradicted the implementation: `attempts` documented as total executions (not retries) and numeric `backoff` as the exponential base capped at 1h; `drain()` noted to also remove delayed jobs; `getJobCounts()` no longer listed as embedded-only; `getWaitingChildren` inclusive `end`; dedup `extend` rejection when the key owner is no longer pending, and the Node SDK's `getDeduplicationJobId()`; embedded `addBulk` accepted-prefix behavior under group `maxSize`; the nine previously missing `JobOptions` fields in the reference table; PostgreSQL `getRateLimitTtl` returning `0`; `RATE_LIMIT_*` env vars described as protocol-level request limiting; stall detection two-phase ~35s timing, backoff-delayed heartbeat-stall retry, `max_attempts_exceeded` precedence over `stalled`, `job:stalled` SSE event name, and external SDK `getDlq` returning raw jobs without `reason`; Worker `removeOnComplete`/`removeOnFail` non-boolean values ignored rather than treated as `false`; `attemptsMade` as attempts consumed so far; batch processor throw semantics vs `setAsFailed()`; SandboxedWorker `completed`/`failed` events emitted after broker confirmation, `maxRestarts` off-by-one budget, and removal of the unverifiable "experimental by bunqueue" wording; Elixir bulk `promote_jobs/1`; and the Bun-only `moveJobToWaitingChildren`. ### Added - Added opt-in durable completed-job retention through `storage.completedRetentionMs`, `BUNQUEUE_COMPLETED_RETENTION_MS`, and the `--completed-retention-ms` server flag. The cleanup tick removes bounded, oldest-first SQLite batches while protecting results owned by live dependency consumers. - Added observable, resumable SQLite startup migrations with per-version markers, durable row/byte checkpoints for legacy payload rewrites, bounded 500-row/8 MiB transactions, progress and duration logs, and a fail-fast guard for databases created by newer binaries. TCP and HTTP listeners now bind only after migration and recovery complete, so no partially initialized service is advertised during a long upgrade. Migration info records go to stderr while stdout remains machine-readable; recovery phase diagnostics use debug level. Any failure after storage opens also closes partial runtime timers, services, and SQLite before rethrowing, so invalid configuration or corrupt data exits promptly instead of wedging before listener bind. - Added the BullMQ Pro compatibility layer without telemetry or NestJS: persistent group pause/resume, atomic group `maxSize`, intra-group priority, group job/priority queries, manual group rate limits, native batch processors with affinity/min-size/timeout and selective member failure, AbortSignal job cancellation/timeouts, structural Observable results, and the `QueuePro`, `WorkerPro`, `QueueEventsPro`, and `JobPro` aliases. - Added first-class job groups to the Bun Queue and Worker APIs. Jobs accept `group: { id }`; ready ungrouped work has precedence, grouped work rotates fairly across IDs, and each group uses ascending priority with FIFO ties. New Queue getters expose queued depth per group, total grouped depth, and active depth. Workers can supply broker-authoritative per-group concurrency and fixed-window rate defaults, while Queue methods set/get/remove local overrides and inspect rate TTL. Overrides intentionally require the corresponding Worker default, and FIFO claim order does not imply serial execution: group concurrency remains unlimited unless configured. Real TCP/Worker E2E coverage verifies ordering, overrides, input validation, SQLite restart recovery, and obliterate cleanup. - Quick Start: a "run it" step after the first snippet, with the run command for each of the seven runtimes and the expected output, plus explicit Rust and Elixir guidance for per-job outcomes where those SDKs have no event emitter. ### Changed - SQLite schema version 37 adds exact per-queue retained-completion counters, queue-scoped/global deterministic retention indexes, a binary-ID tie-break for completed hot-cache recovery, and durable migration-progress bookkeeping. Completed totals now reflect SQLite authority rather than the bounded hot cache, and recovery probes only dependency IDs requested by each pending page. - Updated the root README, internal architecture/feature references, protocol contract, public API types, Queue/Worker/Flow guides, migration/comparison matrices, and regenerated TypeDoc reference for the complete BullMQ Pro compatibility surface. Telemetry and NestJS remain explicitly excluded. - SQLite schema version 36 persists group pause state. PostgreSQL schema version 20 adds group pause and manual rate-limit deadlines and extends the grouped ready index with priority ordering. - SQLite schema version 35 adds durable `group_state` configuration. PostgreSQL schema version 19 adds a `BIGINT CACHE 1` grouped-admission sequence, `group_order`, exact FIFO/rotation indexes, and durable group state for configuration, effective fixed windows, active-capacity calculation, and `last_served`. Group claims, budgets, leases, and cursor movement commit in one transaction, so independent brokers share one exact order and capacity. Startup fingerprints every group column, primary-key semantic, index and sequence setting; bounded retention preserves live windows/overrides while reclaiming inactive rotation state. A v18 broker refuses to start against the upgraded schema; upgrade every broker in the cluster together. - Group limit, duration, and concurrency controls now share positive-safe- integer validation before any state change. PostgreSQL uses `BIGINT` for the corresponding limits, counters, and concurrency state, preserving parity with embedded mode instead of rounding fractions at the database boundary. - Intra-group priority now validates the BullMQ Pro integer range from `0` to `2,097,151` at the shared admission boundary for SQLite, TCP, flows, batches, and PostgreSQL. ### Fixed - Fixed accepted SQLite-buffer jobs exposing or persisting their original waiting state after they had become active, waiting-children, delayed, retried, promoted, or completed. Pending lifecycle state is now explicit, survives a full timeline, and is used by point/list queries and eventual upserts. Automatic threshold/lifecycle flushes respect an outstanding exponential backoff, while batch transitions make at most one materialization attempt instead of exhausting all retries in one call. Evicted buffered completions remain queryable and keep their queue registered; reusing their custom ID retires the pending generation and stale result before admitting its successor. Stable mixed pagination now uses SQLite-compatible Unicode ordering, and non-round-trippable isolated-surrogate job IDs reject before admission. - Fixed explicit `undefined` exceptions being mistaken for “no error” by batch admission and deferred buffer scopes. Error capture now tracks presence separately from the thrown value, preserving JavaScript throw semantics. - Fixed completed SQLite rows becoming unreachable after eviction from `maxCompletedJobs`: `clean(..., 'completed')` now pages across cold database history and atomically removes jobs, results, and flow-failure records. This prevents unbounded disk growth when a retention policy is used and restores exact global/per-queue completed statistics. Trigger side effects are also accounted for when retrying completed jobs, preserving the atomic completed-to-waiting transition in embedded and TCP modes. Equal-timestamp in-memory cleanup now uses SQLite-compatible binary ID ordering instead of locale-sensitive sorting. Specific and bulk completed retry also reach cold SQLite rows; bulk selection uses bounded 500-row oldest-first keyset pages. Cleanup carries persisted queue ownership into cache convergence, preventing an old completion from deleting a newer same-ID generation in another queue. - Fixed queue obliteration leaving cold results, logs, buffered jobs, or orphan child/parent-owned flow-failure rows behind. One storage-first transaction now clears jobs/results, DLQ, flow outbox, completion proofs/pins, telemetry, and queue/group state without materializing every historical job ID. Paired result/log owner indexes stay bounded with their LRUs, and a write failure at any deletion step leaves runtime state intact for an idempotent retry. ACK, ACKB, and FAIL revalidate processing ownership before terminal publication, so obliteration cannot race a late completion into resurrection. Buffered jobs now enter storage ownership before RAM publication, waiting-children callbacks run after complete single/batch/flow publication, and an admission tombstone removes the queue name immediately when a reentrant obliterate wins. Buffered batches coalesce threshold flushing at the outer admission boundary, and SQLite-backed queries merge still-buffered rows before stable pagination; exhaustive embedded reads therefore cannot truncate the unflushed tail. Same-ID guards cover both job and DLQ owners in another queue, including job-derived result and flow-failure deletion. Completed cleanup likewise preserves shared auxiliary rows whenever a same-ID DLQ generation survives. - Fixed completed-only queue registration across cleanup and restart. Queue names now reconcile against exact SQLite counts in bounded batches, retain `waiting-children`, processing-transition, and queue/group-policy ownership, and disappear only after their last durable completion is removed. The `queue:removed` events are deferred until the reconciliation is complete, so synchronous recreation of the same or another queue cannot lose the new registration. Pending single, batch, and multi-queue flow admissions are now reference-counted across callbacks and lock waits; expired temporary rate limits and default-equivalent DLQ/stall settings no longer keep empty queues registered forever. Policy mutators register the queue before a durable write can throw, and empty/recovered queues delete stale queue/group state rows before unregistering their runtime name. Completed-only and policy-only names remain discoverable while their empty heap and secondary group runtime are reclaimed. - Fixed memory-only `removeOnComplete` evidence retaining no source owner: obliterate now removes only the selected queue's bounded proofs, including after eviction and same-ID reuse. Memory-only cleanup also preserves a live completed-only queue. Reusing a completed custom ID that is cold in SQLite now retires its stale result atomically before publishing the successor. Every terminal custom-ID reuse also clears generation-scoped results, logs, and their bounded queue-owner indexes before the successor becomes visible, including completed and DLQ reuse across queues. - Invalid programmatic `completedRetentionMs` values can no longer trigger destructive expiry: direct and server configuration now share the same finite, non-negative, safe-integer normalization. - Native batches now reserve one Worker limiter slot per member only when the batch is ready, so `minSize` accumulation consumes no capacity and concurrent batches cannot exceed the configured start budget. A synchronously throwing batch processor is invoked once, and synchronous Observable completion/error now runs the returned teardown exactly once. Cancelling or timing out any batch member aborts the shared processor signal; impossible global-limiter configurations with `minSize > limiter.max` now reject at construction while larger `size` values continue in bounded chunks. - PostgreSQL group `maxSize` admission now serializes the count-and-insert decision across brokers with sorted transaction-scoped capacity locks. Manual group rate-limit TTL queries now report the live manual deadline, and intra-group priority follows BullMQ Pro ordering (`0` first, then ascending). - A `Job` returned from TCP `Queue.add()` now reflects `group.priority` exactly like embedded mode and TCP bulk admission. - Atomic `FlowProducer` planning now preserves group IDs, intra-group priority, and `maxSize`; invalid group options reject the complete graph before writes. - CI: the deterministic SQLite telemetry batching campaign now has an explicit 15-second timeout, preserving all coverage while avoiding false failures when shared runners temporarily exceed Bun's 5-second default. - Documentation site: hero blocks whose content spanned several source lines made MDX emit a markdown paragraph inside them, producing invalid `

` in `

` and `

` in `

` on 33 pages. The nested paragraph also inherited the 1.75 body line-height, so hero headings rendered with a ~1.75 leading instead of the intended 1.05 and broke across lines. All hero headings and ledes are now single-line, and a CSS guard keeps hero typography correct if it regresses. - Documentation site: `.bq-wrap` sits inside `.content-panel`, which already supplies the page gutter, but added another 3rem, so every hero was inset 48px from the prose below it. Above the 38rem phone layer every `.bq-wrap` on a non-splash hero page now drops that inset, which also keeps pages that continue in `.bq-wrap` sections (the production guide, the blog index) on one left edge from hero to last section. The home splash keeps its inset. - Quick Start: the "React to events" and Simple Mode tab groups were missing languages while sharing `syncKey="lang"`. Starlight syncs tab sets by label, so a reader on Rust, Elixir, PHP or Go silently fell back to the Bun tab and was shown TypeScript. Every synced group on the page now carries the same seven labels. - Quick Start: the MCP setup used `bun add bunqueue` plus `bunx bunqueue-mcp`, but the `bunqueue-mcp` binary ships inside `bunqueue` and is not a package of its own. It now matches the MCP guide: `bun add -g` plus `bunx --package=bunqueue bunqueue-mcp`. - Quick Start: the Python first snippet called `Worker(...).run()` inline, so the `worker` handle used by the later events section did not exist. It now binds `worker` before running it. - Documentation site: the nested-paragraph defect also affected `.bq-lede`, `.bq-chart-title`, `.bq-vs-sum`, `.bq-bench-title`, `.bq-bench-foot` and `.bq-pipeline-caption` blocks on the home page, the comparison page, the production page and two blog posts. Every literal `

`/`` JSX block in the content sources now keeps its text on one line, and the built site has no nested paragraph left. - Documentation site: `bunx bunqueue-mcp` appeared in the home page, the server guide, the cron reference and the env-vars guide. `bunqueue-mcp` is a binary inside `bunqueue`, not a package, so the bare form 404s (the MCP guide already documented that). Every call site, including `README.md`, now uses `bunx --package=bunqueue bunqueue-mcp`. - Documentation site: deployment, server, env-vars, troubleshooting and the production blog post configured persistence through `DATA_PATH` while the env-vars reference documents `BUNQUEUE_DATA_PATH` as canonical. Examples now use the canonical name; the fallbacks are unchanged and still documented. - Documentation site: many tab groups were missing languages while sharing `syncKey="lang"`. Starlight syncs tab groups by label through localStorage, so a group without the reader's label silently fell back to its first tab and showed the wrong language, both between adjacent groups on one page and across pages. All 128 `lang` groups site-wide now carry the same seven labels in the same order, filling gaps with an explicit pointer instead of a silent fallback: Quick Start, webhooks, flow patterns and failures, use cases, examples, troubleshooting, Simple Mode and the BullMQ migration guide. - Documentation site: the flow-patterns guide said per-queue defaults (`queuesOptions`) were supported in the Bun package and the Python SDK only. The TypeScript SDK implements them too (`sdk/typescript/src/flow-types.ts`, `flow-plan.ts`), so the Node.js / Deno tab now carries the real `bunqueue-client` snippet and every mention names all three. - Documentation site: `guide/queue-group` was a `.md` file that used `` and ``. Plain Markdown does not process components, so the page printed the literal `import { Tabs, TabItem } ...` line as body text and rendered its four tab groups as a flat stack of code blocks. Renamed to `.mdx`; the page now renders its tabs and joins the site-wide `lang` sync (128 groups, one label set). The doc-audit fixtures in `test/docs-language-tabs.test.ts`, `test/docs-queue-snippets.test.ts` and `test/documented-feature-coverage.test.ts` follow the new extension: the glob had been silently matching nothing, so the page's four language groups were not label-audited at all. - Documentation site: the SDK guide labels the TypeScript SDK as a single tab, a different vocabulary from the runtime-oriented groups elsewhere, so a reader who had picked Bun or Node.js / Deno matched nothing and fell back silently. Its groups now use their own `syncKey="sdk"`. - Documentation site: Elixir guidance referred to a "telemetry callback"; the Elixir SDK spells that option `:event_handler` on the connection. - Documentation site: the SDK guide offered ACK batching placeholders that pointed PHP, Go, Rust and Elixir readers at a non-existent equivalent. Batching is a TypeScript and Python feature; the other workers acknowledge each job individually, and the tabs now say so. - Quick Start: the persistence section advertised `DATA_PATH`; `BUNQUEUE_DATA_PATH` is the canonical variable and `BQ_DATA_PATH`, `DATA_PATH`, `SQLITE_PATH` are ordered fallbacks. The section now shows the server-mode `--data-path` step and states that a _different_ embedded `dataPath` throws instead of opening a second database. The "Setup" table row also uses `bunx bunqueue start`, like the rest of the page. - The embedded heartbeat-token regression now advances a controlled wall clock and checks each lease's renewal count and expiry directly. Single, acknowledgement, and batch coverage still cross the original expiry without depending on a 50-millisecond real-time scheduling window. - Real-executable CLI campaigns now use a 20-second aggregate test deadline while retaining the 5-second watchdog around every child process. Contended runners no longer kill a healthy final command in the sequential matrix, and individual CLI hangs remain bounded and attributable. - The sustained-churn soak test now records worker-termination-attempt cadence with a monotonic clock and validates both density and full-window coverage. It still rejects sparse, clustered, or long-gap churn, but a contended CI runner no longer fails solely because timer callbacks cannot match their ideal cadence. - Embedded Queue group operations and grouped `add`/`addBulk` now reopen the Queue's explicit SQLite `dataPath` after the shared manager is restarted, rather than silently recreating an unrelated default in-memory manager. - Group FIFO order now uses a hidden monotonic admission ordinal. It remains stable across reverse-sorting custom IDs, equal timestamps, priority/delay changes, SQLite restart, PostgreSQL broker restart, and batch chunk boundaries. - Embedded group controls now validate direct QueueManager calls and commit SQLite before changing runtime policy or waking waiters. Policy-column reset and conditional empty-row deletion share one transaction, so a failure in either statement leaves durable and runtime policy unchanged. Cleanup preserves exact active set/count ownership above 1,000 concurrent groups, and `bunqueue/client` exports the documented group option types. ### Performance - In-batch deduplication replacements now lazily index pending persistence rows and tombstone superseded generations instead of repeatedly scanning and splicing the accepted array. On native macOS arm64 with Bun 1.4.0, the median reverse-order replacement batch of 20,000 inputs improved from 364.858ms to 60.202ms (6.06x throughput), while the ordinary 20,000-input batch remained neutral at 38.428ms versus 37.594ms. - Memory/SQLite group scheduling no longer scans and parks an ineligible prefix of the authoritative priority heap. Synchronous insert/remove hooks maintain lazy ungrouped/delayed heaps, a FIFO lane per group, O(1) circular rotation, and O(1) depth counters under the existing shard lock. Plain queues allocate no group scheduler state; blocked groups create no primary-heap reinsert churn. - A native macOS arm64 A-B-B-A campaign against `c39facb9` placed 5,000 queued jobs from a saturated group ahead of another ready group. Across 14 samples per revision, scheduler median improved from 1.427042ms to 0.019542ms (73.02x) and p95 from 2.762375ms to 0.083417ms (33.12x), with the eligible job in every measured pull. These are dated host engineering measurements, not container or end-to-end application latency claims. - The companion ordinary/mixed campaign records the cost as well as the win. Ungrouped median push/pull changed -3.12%/+0.88%; a 20,000-job half-grouped batch changed +43.18% for admission and +5.64% for claim. The candidate passed the strengthened mixed-order oracle in 14/14 samples while the old revision passed 0/14, so mixed timings are directional rather than same-contract. Raw reports mark the incorrect baseline samples non-comparable and are archived with the dated methodology. The optimization targets blocked-tenant scans, not every group workload. ## [2.9.2] - 2026-08-30 ### Added - Reworked the main Examples page into an explicit beginner-to-advanced path: one embedded job, lifecycle and reliability, process boundaries, workflows, and finally the tested PostgreSQL multi-broker project. Existing section anchors remain stable. New progressively enhanced diagrams let readers step through success, retry, and DLQ transitions or compare embedded, TCP broker, and PostgreSQL multi-broker topologies. The controls are keyboard accessible, announce state changes, respect reduced motion, and retain a useful server-rendered first state. Pure explainer models and documentation regressions verify the reading order, local anchor targets, state boundaries, topology progression, accessibility hooks, and project file-size limit. - Added a complete PostgreSQL multi-broker Examples section backed by an executable project rather than copied snippets. Its disposable Docker topology runs PostgreSQL 18.6, three uniquely identified brokers, an internal runtime network, authenticated TCP/metrics, readiness gates, and a separate SDK image built from the current public package source. Four asserted scenarios cover broker health, multiple queues/workers, bulk admission, behaviorally asserted priority and delayed ineligibility/promotion, retry, progress, logs, events, worker discovery, concurrent custom-ID idempotency, shared pause, a cross-broker single-slot concurrency handoff, fixed-window rate-budget exhaustion/removal, DLQ recovery, and a three-level cross-queue `FlowProducer` graph. The runner rejects prototype-chain scenario names, is import-safe, lazily loads only the selected scenario, and bounds HTTP requests, polling predicates, and scenarios. Multi-phase application cleanup captures synchronous and asynchronous errors without skipping later phases. The verifier validates destructive project overrides, registers cleanup before infrastructure creation, independently attempts resource and image removal, and preserves the original failure status. A real forced-timeout campaign and focused fake-Docker regressions prove those failure paths. The new docs include topology, SDK, reliability, flows, N-broker operations, and a dated engineering validation report; rendered code is imported from the exact tested files to prevent documentation drift. The documentation data guard now resolves Vite query and fragment suffixes before checking those source files, preserving its clean-checkout and tracked-file guarantees for `?raw` imports. The full LLM documentation dump expands the same imports into real fenced source instead of leaking unresolved MDX variables, while the curated `llms.txt` links the new example hub directly. Documentation builds now end with a discovery validator that compares `llms-full.txt` and the sitemap with the content tree, verifies all imported example sources, checks curated links and ordering, and confirms the robots discovery pointers. The sanitized unit image and its explicit context allowlist include the full-text transformer and executable example, keeping both regression suites inside the mandatory isolated repository gate without widening the context to unrelated examples. ### Performance - PostgreSQL notification-driven event retention now uses a non-blocking per-queue advisory-lock attempt instead of occupying a pool connection behind an in-flight writer for up to the configured lock timeout (5 seconds by default). Contended sweeps coalesce into one retry capped at 250 ms, removing lock convoys from high-contention multi-broker bursts and letting the retained event window converge promptly. Manual trim and crash recovery remain blocking and exact; expected contention stays healthy, and shutdown cancels the pending retry. - SQLite lifecycle telemetry now batches `PUSHB`, `PULLB`, and `ACKB` journal writes in one transaction. Events retain their input order, journal retention runs once per affected queue, and completed/failed metric mutations are aggregated per queue/type after simulating scalar pruning exactly. Ordinary subscribers, completion waiters, and webhooks still receive every event; a failed batch rolls back and retries per event so one rejected row does not suppress later telemetry. A deterministic differential suite covers mixed queues, terminal and retry-attempt failures, zero through bounded retention, and out-of-order timestamps. On native macOS arm64 with Bun 1.4.0, the new diagnostic runner's five-run median for 5,000 durable jobs improved from 5,310.42ms (941.55 jobs/s) to 1,689.22ms (2,959.95 jobs/s), a 3.14x complete push/pull/ack lifecycle gain. Per-phase medians improved 2.08x for push, 3.31x for pull, and 4.08x for ack. These host results are diagnostic before/after evidence, not publication benchmarks. - SQLite `PULLB` and retained, result-free `ACKB` now persist active/completed state with one transaction per operation instead of one commit per job. Buffered inserts are flushed first, timelines are encoded outside the transaction, and any rejected row rolls the batch back before scalar retry; result-bearing and `removeOnComplete` batches keep their existing ordering. Differential tests compare raw rows with scalar writes and cover buffered jobs, atomic rollback, public routing, and both fallback paths. Using the same native macOS arm64/Bun 1.4.0 runner for 5,000 durable jobs, the five-run median improved from 1,784.47ms (2,801.95 jobs/s) to 932.63ms (5,361.18 jobs/s), a further 1.91x complete-lifecycle gain. Pull improved from 529.45ms to 153.89ms (3.44x) and ack from 613.98ms to 176.81ms (3.47x); push remained outside the change at 627.75ms versus 602.62ms. These are diagnostic before/after results, not publication benchmarks. - A final native A-B-B-A comparison ran the clean preceding Git revision and the complete candidate in alternating fresh processes. Across 10 measured fresh-database samples per version, the pooled median for the same 5,000-job durable lifecycle fell from 5,195.99ms to 911.90ms (5.70x), while median-derived throughput rose from 962.28 to 5,483.06 jobs/s. The candidate's slowest sample was still 5.44x faster than the preceding revision's fastest sample. Exact raw results and methodology are retained in the local validation artifacts; this remains diagnostic host evidence. ## [2.9.1] - 2026-08-28 > **Multi-broker correctness fix.** PostgreSQL readers no longer keep a stale > queue view when event retention removes history they have not consumed. The > schema version becomes 18, so upgrade every broker in a cluster together: a > 2.9.0 broker started against an upgraded database fails with > `PostgreSQL schema version 18 is newer than supported version 17`. ### Fixed - PostgreSQL multi-broker: a broker could keep a stale queue read model after retention pruned events it had not consumed. Two paths did it. A transaction that writes more queue events than `maxQueueEvents` prunes its own older events before any other broker can observe them, so a reader that applied only the retained tail of that commit kept the pruned jobs in their previous state, permanently once a later checkpoint superseded the pruning one. And a drain that applied a batch without re-checking retention could miss history a newer commit had pruned. A prune watermark is now treated as covered only when the applied commit cursor is strictly ahead of the pruned frontier; `bunqueue_event_prune_watermarks` carries a cumulative, per-queue `self_pruned_commit_seq` that every later watermark inherits, so a self-pruning commit forces one authoritative refresh per broker; and a drain that loaded journal entries always re-scans watermarks against its pre-batch position before applying them. The new `postgres/eventCatchupCursors.ts` owns the per-queue bookkeeping and remembers the frontier already handled, so a stable frontier does not reload the queue on every poll: a reader strictly ahead of the pruned frontier makes no extra refresh at all, while a lagging reader still reloads once per new frontier. (`test/postgres-event-partial-commit-retention.test.ts`) ### Changed - The PostgreSQL schema version is now 18. The migration adds `self_pruned_commit_seq` and replaces the `bunqueue_assign_event_commit` commit-sequencer function; both are applied automatically on the first connection, with no manual step. Because the fix depends on that shared trigger, **upgrade every broker in a cluster**: a 2.9.0 broker pointed at an upgraded database now refuses to start with `PostgreSQL schema version 18 is newer than supported version 17` instead of rewriting the trigger back and silently disabling the fix for every broker. ### Fixed (test harness) - The soak test's latency-drift check no longer trips on a noisy CI runner. Its per-tick statistic is the maximum of 40 probe pushes, so a noisy neighbour elevated most windows of one half and shifted even that statistic's median (18ms to 96ms on GitHub Actions, while every conservation, memory and WAL assertion held). Drift from a bloating internal structure moves the typical latency, not just the tail, so the ratio test now runs on per-tick medians and the tail keeps an absolute ceiling. - The extreme PostgreSQL public-API suite no longer fails when a shared CI runner starves it. Its client command bound was 15s while four brokers, one PostgreSQL and the test process compete for the same cores; a saturated run hit that bound and reported a timeout instead of the exactly-once property the suite exists to check. The bound is now 45s, with the per-job and per-test waits raised to match. Measured on a CPU-limited PostgreSQL 16: 3 failures in 10 runs before, 0 in 6 after. - Test helpers no longer drop stream output while waiting. Racing `reader.read()` against a timer leaves the pending read queued, so the chunk it later receives is discarded and a slow producer looks like a silent one — which is why a spawned server that did print its banner was reported as producing nothing. (`test/stream-reader.test.ts`) - The local CLI end-to-end test no longer reports an empty stdout when the server it spawns loses the race for its reserved ports. It reads stderr too, retries a confirmed bind collision, and waits long enough for a loaded CI runner to finish booting. (`test/cli-invariants-local-e2e.test.ts`) - The multi-process PostgreSQL topology harness now retries broker startup when it loses the race for its probed TCP/HTTP port pair, instead of failing the suite with `Is port in use?`. The probe sockets are released before the broker binds them, so a concurrent worker could win that window. (`test/postgres-process-port-conflict.test.ts`) ## [2.9.0] - 2026-08-28 > **The multi-broker release.** Keep bunqueue's one-file SQLite deployment when > that is the right boundary, or point standalone servers at PostgreSQL and run > several active brokers against one authoritative queue. The public Queue, > Worker, Flow, cron, retry, result, and DLQ contracts stay the same. ### Release highlights - **PostgreSQL without a compatibility driver.** bunqueue uses Bun 1.4's native `SQL` pool, prepared tagged templates, reserved transactions, binary protocol, and reconnecting `LISTEN` subscription directly. PostgreSQL 15, 16, 17, and 18 are CI compatibility targets; 18.6 is the pinned and recommended release. - **Real multi-broker coordination.** Transactional `SKIP LOCKED` claims, database-clock leases, broker-session fencing, commit-ordered durable events, shared rate/concurrency limits, dependencies, cron, workers, job-state/lifecycle metrics, logs, results, and DLQ state live in PostgreSQL—not in one broker's memory. - **Failure is part of the contract.** Two-, four-, and opt-in ten-process campaigns kill lease owners, reset pooled connections, race destructive operations, reuse custom IDs, overflow retained event windows, and verify exact recovery without duplicate delivery or stale-token commits. - **One runtime dependency.** Cron parsing now uses `Bun.cron.parse()` and a small leading-seconds compatibility adapter, so `croner` leaves the published dependency graph and `msgpackr` is the only direct runtime package. - **Same clients, broader topology.** TypeScript, Python, PHP, Go, Rust, and Elixir run the shared protocol conformance suite against both SQLite and PostgreSQL servers. PostgreSQL remains server-only; embedded Bun queues keep the existing memory/SQLite path. ### Compatibility notes - PostgreSQL must be selected explicitly by driver or URL, cannot share a configuration with a SQLite data path, and uses normal PostgreSQL backup/PITR tooling rather than bunqueue's SQLite S3 snapshots. MySQL is not supported in 2.9.0. - The supported cron grammar remains standard five-field syntax plus bunqueue's documented leading-seconds six-field form. Seven-field years and the undocumented Croner extensions `L`, `W`, `#`, `+`, and `?` now fail validation instead of being accepted accidentally. Bun's DST rules are now explicit: missing spring-forward fixed times shift by the gap; fall-back fixed times fire once at their first occurrence, while wildcard minute/hour schedules traverse both occurrences. The latter intentionally differs from Croner for some repeated-hour schedules and is covered for both five and six fields. Before upgrading, replace or remove persisted schedules that use the rejected Croner extensions while a 2.8 broker is still running. Version 2.9 validates the complete persisted collection before advancing any missed schedule and reports the offending name and schedule instead of silently omitting it. Interval definitions likewise require a positive safe-integer `repeatEvery` in milliseconds across public and persisted paths. Invalid input now fails before scheduler, deduplication, or database mutation; a valid calendar schedule retains precedence when both timing fields are present. - PostgreSQL requires `maxQueueEvents >= 1` because retained durable events are part of multi-broker convergence. Memory and SQLite retention behavior is unchanged. ### Added - Added an optional PostgreSQL 15–18, database-authoritative storage driver for standalone servers, with 18.6 pinned and recommended. Multiple brokers can now share transactional job state, `SKIP LOCKED` claims, fenced database-clock leases, durable events, queue limits, cron schedules, worker registrations, logs, metrics, dependencies, repeat successors, and DLQ lifecycle state. A pinned two-broker `docker-compose.postgres.yml` topology and dedicated real-PostgreSQL integration suites cover the distributed path. CI now runs those suites against PostgreSQL 18.6 and the current 17.x, 16.x, and 15.x images; explicit version assertions guard every matrix entry. An additional topology test launches four independent bunqueue processes against one database, verifies exact delivery and shared policies through all endpoints, then kills one lease owner and proves survivor recovery plus stale-token fencing. An opt-in ten-process soak adds 40 concurrent consumers, 25,000-job mixed traffic, simultaneous loss of two lease owners, production lease timing, exact recovery, stale-token fencing, and PostgreSQL deadlock/WAL/temp accounting. Its emitted timings remain diagnostic until raw native-host records and integrity hashes are retained for publication. Two standard public-API suites additionally connect `Queue`, `Worker`, `QueueEvents`, and `FlowProducer` to different members of a four-process cluster. They cover lifecycle/results/logs, pause, custom-ID idempotency, DLQ retry, `removeOnComplete` result retention, zero-cache authoritative reads, and a three-level cross-queue flow in every PostgreSQL 15–18 CI matrix entry. An extreme public-API campaign adds a 256-request custom-ID collision, 256 concurrent remote completion waiters, 32 simultaneous eight-way flows, and active-Worker recovery after the owning broker is killed. - The shared SDK conformance harness can now start isolated SQLite or PostgreSQL brokers. TypeScript, Python, PHP, Go, Rust, and Elixir each run all 18 public protocol checks against both backends in CI and in the SDK sandbox. The sandbox provisions one disposable PostgreSQL 18.6 service on a private Docker-internal network and gives each broker an independently cleaned namespace. A case-insensitive driver policy now removes bunqueue, PostgreSQL/libpq, AWS/S3, storage/TLS, and delimiter-named credential variables while collision tests preserve non-secret toolchain settings. The harness observes broker exit, escalates to `SIGKILL` when required, and only then cleans SQLite or the PostgreSQL namespace. Startup failures follow the same ownership rule; every started suite settles before aggregate cleanup, unconfirmed container names are never force-removed, and Docker teardown failures remain retryable and are reported together with startup errors. - Added `Queue.removeDlqJob(id)` and `removeDlqJobAsync(id)` for permanently deleting one failed job without retrying it. Both methods await the selected embedded or TCP broker and return whether an entry existed. - Added 24 PostgreSQL fast-check property campaigns across six files covering arbitrary payloads, admission/idempotency races, scheduling and dependency ordering, competing claims, shared resource policies, generated lifecycle histories, fencing, retry/DLQ/TTL behavior, omitted progress messages, completion-proof retention, reverse-order generation reuse, destructive dependency safety, event convergence across retained and missed LISTEN windows, and generated commit orders independent of physical event IDs. Seeds and run counts are replayable from environment variables. ### Changed - Dedicated PostgreSQL test commands now reject a missing or blank `BUNQUEUE_TEST_POSTGRES_URL` instead of exiting successfully after skipping the database suite. Explicit smoke, destruction, pressure, and full battle profiles make the production gate repeatable; battle mode enables the ten-broker failure soak and raises every Fast Check campaign to 100 runs. New connection-reset regressions terminate every pooled backend for two brokers, require stable-ID retry plus projection convergence, and kill an admission after its job write to prove transactional rollback and exact retry. Spawned broker diagnostics now drain bounded stdout and stderr captures, classify both human and JSON records, and wait for process exit plus stream EOF before the ten-broker gate asserts that no ACKB failure was hidden. Stream read errors and missing EOF now cancel the remaining pipes but reject the diagnostic gate, so an incomplete capture cannot produce an authoritative zero-failure result. - PostgreSQL schema v17 moves every advisory-lock domain to unambiguous, length-prefixed 64-bit identities. Multi-key admission, dependency, flow, and queue-lifecycle plans deduplicate and order the physical lock keys. Destruction and pressure profiles now include deterministic legacy-hash collisions, bounded core-transaction rollback/replay, retry diagnostics, and the existing ten-process broker-crash soak. - The deployment guide now includes a production-oriented Kubernetes manifest for four PostgreSQL-backed brokers, with unique Pod-derived broker identities, database startup gating, storage-aware probes, graceful termination, connection budgeting, coordinated non-mixed-version upgrades, and a Pod disruption budget. A fresh Kubernetes 1.33.1/kind failure campaign against PostgreSQL 18.6 verified cross-broker jobs and Flow execution, forced lease-owner loss, stale-token fencing, recovery, and automatic Pod replacement. - PostgreSQL documentation now distinguishes the CI-tested 15–18 compatibility range from the pinned/recommended 18.6 deployment, documents broker heartbeat/takeover/recovery timing, Bun SQL pool lifecycle deadlines, safe schema upgrade and rollback boundaries, backend-specific durability/health semantics, and separate SQLite/PostgreSQL sizing. The public benchmark guide now includes the multi-broker version and tuning campaigns, and publishes the seven exact raw JSON artifacts with a SHA-256 manifest. - The README, documentation home, storage/deployment guides, FAQ, security guidance, architecture pages, and social covers now describe the exact SQLite-versus-PostgreSQL topology consistently. A full documentation audit scopes performance headlines to their measured workloads, corrects the Cloud payload and remote-command defaults, validates internal and external links, and publishes a warning-free v2.9 TypeDoc reference while retaining v2.8 as a noindex historical tree. - PostgreSQL uses Bun 1.4.0's built-in `SQL` client directly; no ORM or external PostgreSQL compatibility driver sits on the queue hot path. The implementation uses its native pool, binary protocol, prepared tagged templates, transaction-reserved connections, and reconnecting LISTEN subscription against PostgreSQL 18.6. - Cron calendar, timezone, POSIX day matching, and DST evaluation now use Bun 1.4.0's native `Bun.cron.parse()` API. A focused compatibility adapter keeps bunqueue's documented six-field syntax by parsing the leading seconds field, including lists, ranges, and steps, while the scheduler and SQLite/PostgreSQL persistence paths remain unchanged. `croner` is no longer a published runtime dependency, leaving `msgpackr` as the only one. Seven-field years and the previously accidental `L`, `W`, `#`, `+`, and `?` Croner extensions are now rejected explicitly because they were never part of bunqueue's public grammar. - Memory and SQLite remain the inferred/default backends and retain their synchronous behavior. PostgreSQL is selected only from an explicit driver or URL, is server-only, cannot be combined with a SQLite data path or SQLite S3 snapshots, and does not imply MySQL support. Explicit `memory` construction ignores an inherited SQLite data path; the SQLite Strategy and hot path remain unchanged. - PostgreSQL `WaitJob` now recognizes durable completion-only generations when a queued request reaches the broker after `removeOnComplete` deleted the live row. A discriminated asynchronous completion port preserves valid `undefined` results, performs one authoritative PostgreSQL read, and leaves the existing memory/SQLite missing-row behavior unchanged. - PostgreSQL `IsPaused` now reads durable queue state after pause/resume instead of relying on the eventually consistent local projection. This closes the commit-to-LISTEN read-your-write window seen by every network SDK while preserving the existing synchronous memory and SQLite path. - Server persistence selection now uses explicit Strategy, immutable Registry, and lifecycle Facade boundaries. Fake adapters unit-test validation, creation, display, concurrent shutdown coalescing, and retry after transient shutdown errors; PostgreSQL feature code remains split into focused transaction scripts with explicit SQL contexts behind its store Facade and Snapshot read model. Dependency completion uses an immutable Lock Plan/Command, while event health, bounded startup capture, and deferred write serialization are independent components. Failure and race paths can therefore be exercised without coupling every test to server bootstrap. - Cloud command and snapshot selection now uses its own complete Strategy and cached Registry. Memory/SQLite delegates to the existing local behavior; PostgreSQL shared jobs, counts, queue configuration, lifetime terminal totals, results, logs, workers, crons, and leases come from durable APIs and one bounded `REPEATABLE READ READ ONLY` snapshot. There is no silent per-method fallback to a broker's compatibility cache. - PostgreSQL completion evidence is now generation-scoped and bounded. Live dependency proofs are pinned, unreferenced `removeOnComplete` tombstones keep the newest `maxCompletedJobs`, the compatibility snapshot independently caps completed rows and its `maxJobResults` LRU, and schema v15 adds the queue/recent completion indexes plus durable queue-registry backfill. Tombstone cleanup commits in 1,000-row batches until it reaches the exact bound, runs before startup readiness, retries post-commit failures, and has a periodic repair sweep for interrupted cleanup or configuration reductions. PostgreSQL rejects `maxQueueEvents: 0` explicitly because multi-broker convergence needs at least one durable event; SQLite and memory behavior is unchanged. - The full unit suite now uses Bun 1.4 file parallelism with four isolated workers in the local package script, disposable Docker sandbox, and GitHub Actions. The shared configuration is covered by a repository gate to prevent the three entry points from drifting. - PostgreSQL Fast Check cleanup now deletes all generated namespaces per scope in one set-based transaction and gives database hooks an explicit deadline, so deep campaigns preserve isolation without exhausting pools or timing out after otherwise successful properties. - PostgreSQL cron overlap coverage now waits for durable `next_run` using the database clock and permits either broker to win the row lock, eliminating a host-timing race with automatic maintenance without weakening execution-limit assertions. ### Performance - PostgreSQL multi-broker hot paths now bound journal catch-up reads at 4,096 events and authoritative projection repairs at 1,000 IDs, remove a redundant autonomous queue-state insert from claims, and update exact metric buckets plus lifetime totals through a focused canonical-order CTE writer. Concurrent older transactions can no longer move metric `prevTS` or its latest-minute count backward. TTL expiry remains autonomous to preserve lock order, and SQLite behavior is unchanged. - The native PostgreSQL runner now records broker pool size, polling interval, server `work_mem`, and dirty runtime-source status. A PostgreSQL 18 bottleneck report documents controlled code A/B evidence, 100,000-job activity sampling, batch/pool/`work_mem` sweeps, rejected optimizations, exact integrity totals, and raw artifact hashes. Batch 250 raised the four-broker lifecycle median from 7,478 to 8,362 jobs/s versus batch 100 with 41.7% fewer commits, while explicitly reporting higher command tails, WAL/job, and temporary spill. - Added a reproducible native PostgreSQL 15–18 benchmark harness and a dated engineering report covering PostgreSQL 15.19, 16.15, 17.11, and 18.6 across one, two, and four independent broker processes. The campaign ran 84 measured 10,000-job samples after 12 discarded warm-ups, retained exact accepted, invoked, and completed ID sets with zero duplicates/deadlocks/temp spill, and reports admission/processing/lifecycle medians, CV, Student-t CI95, command p95 latency, WAL per job, and broker fairness. PostgreSQL lifecycle medians were 6,550–6,945 jobs/s with one broker, 8,004–8,494 with two, and 7,168–7,788 with four on the native M1 Max host. - PostgreSQL bulk admission, claims, unique-ID ACK batches, durable events, and completion metrics now use set-based statements. New custom IDs and deduplication keys share the bulk fast path; ID/key conflicts roll the whole attempt back before the serial compatibility retry preserves generation reuse and reject/extend/replace semantics. Invalid-token ACK batches remain atomic, while dependent/parent admission retains its full semantic path. - PostgreSQL push batches refresh only their affected IDs instead of reloading, sorting, and decoding the complete queue after every commit. Event retention now deletes through an indexed cutoff rather than ranking the entire journal. A per-ID event watch preserves a newer snapshot mutation without restarting the set query for unrelated or already-committed local events. This removes the repeated O(n) work and redundant reads that made fixed-size pushes super-linear. - Default-policy PostgreSQL claimers now use compatible queue-state share locks; queues with rate/concurrency policy retain the exclusive lock required for exact shared capacity. Indexed FIFO, LIFO, group, active-group, and TTL probes choose a narrow-ID `SKIP LOCKED` plan before payload retrieval. Current-row eligibility rechecks prevent a concurrent claim from issuing a second token. - Worker completion waves now coalesce their follow-up poll. A 64-slot Worker requests one 64-job `PULLB` instead of issuing 64 concurrent one-job pulls, preserving the same concurrency, limiter, group, lease, and SQLite behavior. Startup, timer, and resume polls remain immediate; only completion callbacks share the deferred dispatch, so already-resolved embedded query loops and timer ordering retain their existing behavior. - Two native PostgreSQL 18.6 deep campaigns ran the same 180 measured samples across 56 scale, concurrency, batch, payload, broker, feature, and streaming scenarios. Each campaign submitted 696,000 jobs with exact ID conservation, zero duplicate invocations, and zero deadlocks. The optimized PostgreSQL scenarios improved by a 3.01x geometric-mean throughput factor while SQLite controls remained 1.00x; temporary spill fell from 6,176 files/54.97 GiB to zero. At 25,000 jobs PG1 lifecycle rose 309 -> 1,108 jobs/s; custom-ID and deduplication batch admission rose ~31 -> 8,414/8,244 jobs/s. These native, non-quiesced results are engineering evidence, not published cross-project claims. - A two-broker `pg_stat_statements` campaign with 16 claim loops and 20,000 jobs measured 11,749 admission, 9,782 processing, and 5,338 lifecycle jobs/s with exact delivery, zero deadlocks, and zero temp files. Against the pre-claim profile, processing improved 3.06x and lifecycle 2.24x; queue-state lock time fell from 88.4 s aggregate to 3.5 ms, and 200 push batches now issue exactly 200 affected-ID reads instead of roughly 380. A separate 21-instance safe-settings matrix (420,000 jobs) found only a host-specific +6.3% median at `shared_buffers=512MB`; AIO/JIT/WAL variants were close enough that bunqueue does not impose server-wide tuning or weaken PostgreSQL durability. - A final 60-sample/18-scenario end-to-end subset submitted 423,000 jobs per compared campaign and improved another 1.16x geometrically over the first optimized PostgreSQL pass. PG1/PG2 at 25,000 jobs reached 1,569/2,078 jobs/s, four brokers reached 2,428 jobs/s at fixed total concurrency, and every sample retained exact delivery with zero duplicates/deadlocks. Five focused final samples measured 8,635 custom-ID and 8,212 deduplicated admissions/s. SQLite controls ran the unchanged engine and measured 0.96x on the non-quiesced host. - A post-watermark native run used one warm-up plus five fresh 10,000-job samples for SQLite, one PostgreSQL broker, and two PostgreSQL brokers. Median lifecycle rates were 767, 1,986, and 2,638 jobs/s respectively, with 1.5%, 0.5%, and 0.6% variation. Every sample completed the exact accepted ID set with zero duplicate invocations; these remain local diagnostic measurements. - The final commit-envelope journal repeated the same native protocol after the commit-order fix. SQLite/PG1/PG2 median lifecycle rates were 763/1,868/2,474 jobs/s with 1.3%/0.4%/1.4% variation. The immutable-envelope design improved PG1/PG2 by 16.8%/26.0% over the first correct hot-row sequencer, retained exact 10,000-ID delivery and zero duplicates, and left SQLite on its unchanged path. - The compatibility-final candidate repeated the native one-warm-up/five-sample protocol after queue-refresh retry and health reporting were complete. SQLite/PG1/PG2 median lifecycle rates were 717/1,552/2,218 jobs/s with 1.7%/1.5%/3.9% variation on the non-quiesced host. Every 10,000-job sample retained exact ID conservation and zero duplicates; the two PostgreSQL brokers split work 4,992/5,008 or 5,008/4,992. - A focused post-lock-order native campaign made two stores submit the same 10,000 custom IDs in opposite 500-job batches. Across one warm-up and five fresh PostgreSQL 18.6 samples, median time was 2,271.9 ms: 4,402 unique durable jobs/s or 8,803 attempted admissions/s with 1.7% variation. Every sample kept exactly 10,000 rows with zero errors and zero deadlocks. - The final generation-lifecycle candidate repeated the native lifecycle campaign after review fixes. SQLite/PG1/PG2 medians were 759/1,792/2,335 jobs/s across five fresh 10,000-job samples per topology, with 0.5%/0.6%/1.0% variation. PostgreSQL admission medians were 9,922/9,465 jobs/s. Every sample retained exact accepted/invoked/unique/terminal ID conservation, zero duplicates, and balanced two-broker processing. - The final queue-lifecycle, durable Cloud read-model, and post-commit maintenance candidate repeated that native campaign. SQLite/PG1/PG2 medians were 766/1,733/2,276 jobs/s across five fresh 10,000-job samples per topology, with 1.1%/0.9%/1.9% variation. PostgreSQL admission medians were 9,622/9,372 jobs/s. All samples retained exact accepted/invoked/unique/terminal ID conservation and zero duplicates, and both PostgreSQL brokers processed work in every two-broker sample. - The final event-retention candidate repeated the native one-warm-up/five-sample protocol after the contention fix. SQLite/PG1/PG2 lifecycle medians were 750/1,525/2,492 jobs/s with 0.1%/0.4%/1.8% variation; enqueue medians were 4,321/9,121/8,492 jobs/s. All 150,000 measured jobs preserved exact accepted, invoked, unique, and terminal ID sets, zero duplicates, and two-broker participation with PostgreSQL durability fully enabled. - The shutdown-drain and DLQ-repair candidate repeated that full native campaign after the final lifecycle changes. SQLite/PG1/PG2 lifecycle medians were 722/1,366/2,357 jobs/s with 1.4%/1.0%/2.1% variation; enqueue medians were 4,207/7,708/7,960 jobs/s, and PostgreSQL processing medians were 1,641/3,376 jobs/s. All 150,000 measured jobs preserved exact accepted, invoked, unique, and terminal ID sets with zero duplicates, and both brokers participated in every PostgreSQL two-broker sample. - The completed lifecycle-gate and atomic-child-removal candidate repeated the same native campaign. SQLite/PG1/PG2 lifecycle medians were 738/1,462/2,530 jobs/s with 0.6%/2.3%/1.6% variation; enqueue medians were 4,309/8,639/8,425 jobs/s, and PostgreSQL processing medians were 1,756/3,617 jobs/s. PostgreSQL lifecycle medians improved by 7.0% and 7.3% over the immediately preceding one- and two-broker candidate. All 150,000 measured jobs retained exact accepted, invoked, unique, and terminal ID sets, zero duplicates, and two-broker participation with full PostgreSQL 18.6 durability. ### Fixed - PostgreSQL Prometheus per-queue gauges and exported/omitted cardinality now read the bounded local PostgreSQL projection instead of the unused SQLite/in-memory cache. Cross-broker values converge through the committed event stream and polling repair. - PostgreSQL atomic flow admission no longer repeats an admission lock, clock read, completion scan, queue registration, and full event-retention cycle for every graph node. `PUSHF` now reuses its complete outer lock plan, samples one post-lock transition timestamp, and batches ordered `pushed` events plus queue registration while preserving one transactional graph. A nine-job durable regression proves all rows, eight edges, two queue identities, event order, initial states, and timestamps; a forced `55P03` replay proves no duplicate timeline, edge, or event. - PostgreSQL core admission, claim, ACK, ACKB, and FAIL operations now replay once with jitter after rollback-certain `40001`, `40P01`, or `55P03` errors. Connection and statement-cancellation failures are never guessed or replayed. Regressions hold the deferred event-commit lock through the first attempt and prove exact job, result, event, metric, and timeline state; retry exhaustion remains bounded and leaves no partial transition. - PostgreSQL advisory locks no longer alias distinct client-controlled job IDs, deduplication keys, flow parents, or queue names through 32-bit `hashtext` collisions. Fixtures verified across PostgreSQL 15–18 prove both same-identity exclusion and independence for colliding dependency IDs and queue names. Exhausted ACKB infrastructure errors stay redacted on the wire while bounded local logs retain SQLSTATE and trigger-location diagnostics. - PostgreSQL schema initialization now validates the exact unique, key, access method, and live-state predicate semantics of `bunqueue_jobs_live_unique_key_idx`. A weaker same-name index is rebuilt in the migration transaction, while pre-existing duplicate live keys fail closed and roll back without mutating data or leaving a partial repair. - SQLite DLQ and completed retries now restore live custom-ID and unique-key ownership. Every DLQ retry variant persists before publishing RAM, rejects a key owned by another generation without dropping terminal work, and leaves memory plus disk unchanged after a storage failure. The minimized model seed that exposed the bug is retained as a deterministic regression. - PostgreSQL `GetResult` and `WaitJob` now read completion results through an asynchronous authoritative result port. A broker waiting on work completed by another broker can no longer return `completed: true` with an undefined result before its local projection refreshes. PostgreSQL waits register a cancellable event waiter before rechecking the completion table, closing the check-before-subscribe race without polling; memory and SQLite retain their existing synchronous result behavior. - PostgreSQL DLQ creation now derives entry, attempt, retry, and expiry timestamps from the transaction's database clock. Age maintenance no longer compares a broker-host timestamp with PostgreSQL time, which could delay a short `maxAge` purge when the broker clock was ahead. A deterministic multi-broker regression advances the broker clock by 60 seconds and proves exact purge plus invalidation convergence; memory and SQLite keep their existing clock behavior. - PostgreSQL authoritative queue refreshes now fence older in-flight per-job projections immediately before replacing the local read model. Completion projections retain the queue identity stored in PostgreSQL instead of an optional caller hint, so a remove-on-complete result cannot be assigned to an empty queue and survive or reappear after a concurrent multi-broker `obliterate`. Deterministic generation tests and repeated PostgreSQL 16 regressions cover both the stale-read ordering and queue ownership. Memory and SQLite behavior remain unchanged. - PostgreSQL production hardening now session-fences every broker process. Duplicate live `brokerId` values fail startup; stale takeover installs a new internal session, and old shutdown/heartbeat/claim/renew/worker operations cannot affect successor leases or rows. Schema v16 adds session columns and exact cleanup indexes. Bun SQL connections now set statement, lock, and idle transaction deadlines; the manager bounds active/queued operations and fails fast on saturation. Manager/queue snapshots reject explicit job or payload budgets before decoding instead of risking an unbounded allocation. Startup lifetime metrics finalize against the durable commit sequence, adaptive journal GC drains sustained envelope backlogs, and same-key post-commit maintenance is serialized and coalesced without overlap. The default pool is four connections per broker, CI covers PostgreSQL 15–18, and SQLite behavior remains unchanged. Concurrent broker startup now elects one oldest live session under the cron advisory lock, so every broker cannot simultaneously skip missed-schedule reconciliation. - PostgreSQL lifecycle and asynchronous concurrency now separate committed database outcomes from fallible local projections. Push, flow, ACK/fail, queue control, maintenance, and relationship mutations keep their committed result while a generation-fenced projection scheduler reports and retries a failed read. Historical journal payloads no longer overwrite a newer local claim or clear its token; only an authoritative current row can do so. Unique projection-flight identities preserve stale-read fencing while settled generation entries are reclaimed instead of growing once per historical job. Bootstrap and per-queue views use coherent `REPEATABLE READ READ ONLY` snapshots, startup overflow retries are bounded, and queue refresh cannot keep shutdown in an unbounded quiet-window loop. Client lease release retains exact token sessions and cumulative progress across retries. Periodic work is single-flight per subsystem, and store shutdown drains all admitted periodic and post-commit maintenance before releasing broker resources or closing the SQL pool. Memory and SQLite code paths are unchanged. - PostgreSQL shutdown now uses one reentrant lifecycle gate for database-backed admissions, batch/flow pushes, individual claim attempts, durable mutations and reads, startup hydration, and synchronous deferred writes. Operations admitted before shutdown drain through their final snapshot refresh, while late or escaped work fails before reaching the closed pool. Empty long-polls no longer hold shutdown open, and late disconnect cleanup remains local and idempotent. A committed operation can therefore no longer return a pool-close error merely because another caller started shutdown. - PostgreSQL `removeUnprocessedChildren` now removes direct pending children in one canonically locked transaction. A fixed-point consumer analysis retains and detaches children required by surviving jobs, while waiting, prioritized, delayed, and safe waiting-children generations are deleted with exact durable events. Active and terminal children preserve leases, results, completion evidence, and DLQ state. The command is idempotent across brokers; the existing synchronous SQLite implementation and behavior are unchanged. - PostgreSQL event retention now uses non-blocking per-queue locks on inline writers, deterministic tuple locking, and commit-aware autonomous sweeps. Same-queue completion contention and inverse multi-queue write orders no longer produce `40P01` deadlocks, while a fresh post-commit snapshot converges the retained journal to its exact configured bound. Queue obliteration takes lifecycle then retention ownership before job rows and deletes event history before its watermark, closing the manual-trim inversion as well. - Preserved Cloud `queue:detail` responses for SQLite queues that only have configuration state and no jobs. The async adapter now requests the named queue explicitly, so `stallConfig.enabled` and the remaining configured values do not fall back to defaults. - Cloud job pagination now uses one half-open range contract across SQLite and PostgreSQL, with normalized non-negative limits and offsets. Regular remote commands and `snapshot:get` retain raw infrastructure details in local logs but redact them from dashboard responses. - PostgreSQL admission and queue deletion now share a queue-lifecycle lock domain. Admissions take compatible shared transaction locks; `obliterate` takes the exclusive lock before queue state, discovers candidates inside the transaction, and removes only the generation committed before its linearization point. Repeat ACKs use a late try-lock and roll back atomically on conflict, preventing deadlocks and split successor chains. Queue identity survives final-job removal and restart without phantom registrations from duplicate IDs. - Completion/DLQ pruning now runs through a keyed post-commit maintenance executor. A committed ACK/FAIL/discard is never reported as rejected because idempotent retention failed; health stays degraded, failed work is coalesced and retried, and invocation-identity fencing prevents a late success or failure from deleting or reporting for newer work after a key is reused. Shutdown now closes maintenance admission without touching an already closed pool and drains terminal operations admitted before shutdown through their final snapshot refresh. Startup and periodic DLQ sweeps repair skipped retention using the current queue policy under lock and remain idempotent when multiple brokers run them concurrently. DLQ auto-retry now locks current policy and the complete consumer/dependency identity plan before failed rows, revalidating edges after lock waits so dependency custom-ID reuse cannot promote a consumer from stale completion evidence. Four concurrent brokers still emit one retry. - Server shutdown now runs through a memoized coordinator. Duplicate signals share one task, optional backup/Cloud failures cannot skip storage cleanup, transient storage close is retried once with a timeout, and permanent failure exits non-zero instead of recursing into an already-guarded rejection handler. - PostgreSQL list ordering now uses binary ID comparison, an empty state array means all states, repeated lease renewal refreshes expiry/heartbeat/TTL/count, and durable completed/failed totals survive removal, clean, purge, and broker restart. Local worker lifetime processed/failed totals now survive worker unregistration. - PostgreSQL destructive writers now share dependency identity locks with admission. Cancel/remove, clean, TTL, drain, DLQ limit/expiry/purge, terminal retry, `removeOnFail`, protected-cron cleanup, dedup replacement, and obliterate revalidate candidate/live-consumer rows and cannot leave a live `waiting-children` job without either its producer row or completion proof. Queue obliteration also removes completion-only rows and rejects external live consumers. New deterministic and Fast Check campaigns cover both lock orders, all adapters, custom-ID generation reuse, four brokers, and schema 13-to-15 migration. - Reusing a custom ID after `removeOnComplete` now retires the old completion before inserting the new generation on both serial and set-based batch paths. Serial admission resolves deduplication first, so a candidate that returns a different owner preserves its completion-only or retained terminal generation. Reverse-order batches exempt only consumers inserted in the same transaction, reconcile every surviving row against final proof, and correct the original `pushed` payload without reordering `pushed`/`removed`. Late-ID replacement retains its non-blocking identity probe, while fast-path conflicts roll back before completion retirement and enter the selective serial path. - PostgreSQL progress updates now preserve the previous message when the next update omits `message`, matching the unchanged SQLite contract. Awaited and deferred disconnect cleanup also freezes every `(jobId, token)` before its first asynchronous boundary, so delayed work cannot release or forget a newer custom-ID generation. - PostgreSQL dependency validation now reaches the authoritative database when a receiving broker's event snapshot lags, and admission reasserts job or completion evidence inside the write transaction under the canonical dependency locks. Immediate broker-A-to-broker-B `PUSH`/`PUSHB`, reverse-order same-batch parents, removal between preflight and admission, and a planned parent deduplicating to another ID are covered without orphan jobs or partial commits. - PostgreSQL health errors now make HTTP health/readiness, WebSocket health, and `bunqueue_storage_degraded` report degradation even when the failure is not a full disk. Client-facing health, storage-status, dashboard, MCP, and Cloud payloads redact non-disk SQL/network diagnostics while preserving the existing actionable SQLite disk-full response. Local handler catches and the outer HTTP boundary use the same sanitizer, and dependency reads no longer turn database errors into successful empty maps. - Failed-job `MoveToWait` now dispatches to PostgreSQL's durable DLQ retry while retaining the synchronous SQLite path. PostgreSQL dashboard commands, HTTP dashboard and per-queue worker routes, and WebSocket/SSE stats snapshots now read the shared worker and cron registries instead of one broker's local maps. - The root CI quality gate now explicitly requires the PostgreSQL 18.6/17/16/15 compatibility job, preventing build and release jobs from proceeding after a failed or cancelled database matrix. - PostgreSQL single and bulk admission now lock the complete custom-ID and deduplication-key union in one canonical set-based order. Two brokers can submit the same 500 IDs in reverse order without a `40P01` deadlock. Dynamic parent attachment, explicit failure, detach, and expired-lease recovery also share the child relationship lock, re-read its current parent, and acquire sorted parent locks before job rows, closing the terminal attachment TOCTOU window. - PostgreSQL rate limits now normalize non-positive duration and TTL values exactly like SQLite: the duration uses the one-second default and the TTL is permanent. Periodic health is tracked per subsystem, so a successful heartbeat cannot erase a persistent recovery, DLQ, cron, event-stream, or queue-refresh failure. - PostgreSQL job-log writes and retention now serialize on the owning job row. Concurrent writers retain the exact requested maximum, and a concurrent removal cannot leave an orphan log. Protocol error boundaries also redact PostgreSQL SQLSTATE, constraint, driver, host, SQLite, and network diagnostics while preserving intended domain errors. - The PostgreSQL Compose topology no longer interpolates the raw `POSTGRES_PASSWORD` into broker URLs. Operators provide a separately percent-encoded `BUNQUEUE_POSTGRES_URL`, with a valid default for local use. - The disposable unit-test image now includes the PostgreSQL Compose manifest, allowing its credential-safety regression to run inside the mandatory network-isolated sandbox as well as on the host. - PostgreSQL queue obliteration now locks shared queue state before job rows, matching the claim hierarchy and preventing a deterministic multi-broker deadlock. Completion snapshots also discard stale results after retry, clean, removal, and custom-ID generation reuse. - PostgreSQL DLQ size eviction and age expiry now publish one transactional queue invalidation per affected queue. Live invalidation markers are applied independently of journal retention and deduplicated during replay. Terminal and retry events now carry complete DLQ entry/retry state, and pruning writes its invalidation after the terminal event so cursor replay retains it. Remote DLQ lists, entries, counts, and stats therefore converge even after a missed notification with a one-event queue-event window. - PostgreSQL schema v13 adds a deferred commit sequencer for the transactional event outbox. A namespace transaction advisory lock plus a global `CACHE 1` sequence preserves same-namespace commit order. Immutable event rows reference a compact per-transaction commit envelope, avoiding a second event rewrite and its index/WAL amplification; unreferenced envelopes are collected safely. Brokers upgrade v12 in place, reject a newer recorded version, verify the exact semantics of every correctness-critical journal object before the no-DDL fast path, and repair detected drift under the migration advisory lock. The guard covers sequence properties, column definitions, ordered indexes/predicates, trigger bindings, and normalized function bodies while preserving index object IDs on a healthy schema. This prevents both startup DDL deadlocks and silent replay with a missing trigger or index. - PostgreSQL broker/client shutdown and recovery preserve lease fencing across pooled cross-broker heartbeats, discard protected cron leases instead of resurrecting overlap work, and reconcile missed cron slots only when no other active broker owns the namespace. Distributed lifecycle deadlines use the database clock to tolerate broker clock skew. - PostgreSQL event replay now polls the durable outbox by `(commit_seq, event_id)` and treats LISTEN/NOTIFY only as a wake-up. Pre-commit envelope stamping is abort-safe and independent per namespace, so a lower physical ID committed late cannot be skipped. Drain requests are bounded/coalesced; failures remain visible in health until a complete durable scan succeeds. - PostgreSQL event pruning now records a transactional per-queue durable watermark with a cumulative monotonic pruned-commit frontier. Brokers that missed discarded single or batch history refresh only the affected queue, while already-current brokers keep the incremental path; no global `BIGSERIAL` gap heuristic or synthetic public event is used. Manual trim now derives that frontier from deleted commit envelopes and does not invalidate an already-current broker. - PostgreSQL dependency completion now promotes every newly ready parent in the same transaction as ACK/ACKB, including `removeOnComplete`, payload timeline, state, version, and durable event. Canonically ordered dependency and parent locks close concurrent fan-in and late-consumer admission races; claim-time promotion remains an idempotent repair path. - PostgreSQL snapshot startup now uses a bounded 256-event accumulator across initial hydration and retries the authoritative load after overflow, and every point/batch refresh—including stale null reads—uses the same per-job version fence. Deduplication replace/expiry/extend transitions publish atomic cache updates, so remote brokers cannot retain an obsolete generation, unique key, TTL, result, or lifecycle state. Failed queue invalidation refreshes now preserve their dirty marker and retry with bounded backoff instead of silently leaving a broker with only the retained journal subset. Their per-queue errors remain visible in storage health until success, and shutdown stops persistent retry loops. - PostgreSQL worker heartbeat and unregister operations are fenced by the current broker and connection owner. A stale connection cannot overwrite or delete a worker re-registered elsewhere. Deferred compatibility writes retain ordered failures until flush. Concurrent flushes at the same sequence share one checkpoint and observe the same errors; shutdown drains them before reporting an error. - PostgreSQL manager/store shutdown is coalesced for concurrent callers and retryable after a transient cleanup error. Lease, worker, broker, event, and SQL-pool cleanup steps are tracked independently; adapter ownership is removed only after the complete attempt succeeds. - Selective DLQ deletion is now restart-safe and complete. It propagates broker failures, closes the discard/removal persistence race, removes all recovered duplicate rows, and releases terminal custom-ID, dependency-result, result/log, job-index, and flow-failure ownership. ## [2.8.61] - 2026-08-22 ### Fixed - Fixed the order-dependent Linux release failure where persistent embedded suites inherited a process-wide in-memory QueueManager before selecting their SQLite database. The affected Workflow, Worker, and stacktrace suites now claim the singleton at file entry and release it only after their clients close. A dedicated regression guard locks both suite boundaries. ### Changed - Updated the repository agent instructions so every commit contains a corresponding changelog update and uses a concise, specific English description as its mandatory message. Pushes now verify outgoing commits without starting another changelog cycle; version bumps and package publication remain separate actions requiring explicit authorization. ## [2.8.60] - 2026-08-22 ### Fixed - Fixed workflow generation races exposed by Bun 1.4. After `close(true)`, the old Engine can no longer persist, enqueue, emit, or start user code from a late retry, map, loop, decision, child poll, wait, lifecycle call or recovery continuation. Graceful close still drains, and already-started forward and compensation handlers retain their documented at-least-once semantics. ### Changed - Raised the Bun runtime floor to 1.4.0. CI, release builds, the production and validation images, SDK workflows, local Compose development, and Bun type definitions now use Bun 1.4.0 consistently. - Replaced Biome with pinned Oxlint and Oxfmt tooling in the root project and TypeScript SDK, including type-aware linting, CI, pre-commit hooks, isolated validation images, suppression directives, and contributor documentation. ## [2.8.59] - 2026-08-05 ### Fixed - Fixed the order-dependent Linux release failure where the in-memory Workflow loop suite left the process-wide embedded manager alive before a SQLite-backed timeout regression selected its database. Both suites now claim and release the singleton explicitly, and a child-process regression locks the exact file order that blocked the 2.8.58 release gate. ### Distribution - GHCR releases now publish the exact package version tag alongside `latest`, the commit SHA, and the timestamp tag. Generated GitHub release instructions use the immutable version tag, so npm and container deployments can be pinned together. This completes the container publication that 2.8.58 missed when its unit gate failed; runtime behavior is unchanged from the verified 2.8.58 Worker polling fix. ## [2.8.58] - 2026-08-04 ### Fixed - Queue now releases its constructor-owned shared TCP pool reference exactly once, so duplicate `close()` calls and `disconnect()` followed by `close()` cannot break unrelated Queue instances. Worker shutdown is also monotonic: stale `run()` or `resume()` callbacks are ignored after `close()` begins and cannot process another batch during teardown. - Made graceful cancellation, priority aging, scheduled S3 backups, and Cloud Agent timers lifecycle-safe. Repeated starts/cancels now retain a single owned handle, cleanup cannot leave an orphan generation behind, and stale queued callbacks cannot resume work after stop. Existing cancellation timing remains earliest-deadline-wins; synchronous processor and middleware throws also release their exact cancellation generation, including when a user circuit-breaker hook throws during outcome notification. In-process retry now handles synchronous throws identically to rejected Promises, and cancellation or `close()` clears an armed backoff so processing cannot resume after shutdown. Circuit-breaker destruction is now terminal, so a retry rejected during shutdown cannot rearm its reset timer; explicit cancellation keeps its existing cooperative success/failure outcome behavior. - Fixed [#113](https://github.com/egeominotti/bunqueue/issues/113): Worker poll wake-ups are now coalesced into one earliest-deadline timer. A later backoff cannot postpone a wake-up that was already due sooner. Completed jobs no longer leave self-perpetuating timer chains that make idle CPU grow with the processed-job count, and pause/close clear the same timer ownership state. - Embedded Queue, Worker, and QueueEvents construction now fails synchronously when an explicit `dataPath` conflicts with the process-wide QueueManager's active database. Paths are canonicalized so equivalent relative, absolute, and symlink spellings remain compatible; omitted paths continue to join the active manager. This prevents `durable: true` jobs from being accepted into memory after an earlier client initialized the singleton without storage. ### Removed - Removed StrykerJS from the TypeScript SDK — mutation job, config, script, dev dependency and the `qs` override it needed. Its dependency graph produced every finding the weekly advisory gate reported (`qs` via `typed-rest-client`, then `fast-uri` via `ajv`), none of it reachable from the published client. The planners keep their fast-check coverage and the other five SDKs still run mutation. The dev dependency tree drops from 163 packages to 10 with a clean `bun audit`. ## [2.8.57] - 2026-08-03 ### CI and package verification - Fixed an order-dependent CI failure caused by a Workflow production test leaving the process-wide embedded manager initialized in memory. Its teardown now pairs `Engine.close()` with `shutdownManager()`, preserving the documented shared-manager ownership contract while preventing the following durable parent restart test from using the wrong backend. - Added an offline consumer test for the exact npm tarball. It verifies the manifest exports and imports `defineConfig` from `bunqueue`, Queue and Worker from `bunqueue/client`, and Engine from `bunqueue/workflow` after packing the release. The consumer unpacks the archive into its own `node_modules` and links only the declared dependencies, proving `croner` and `msgpackr` are enough for every entrypoint. The CLI and runtime remain Bun-only by design. - Fixed the scheduled Go mutation job, which aborted before its first mutant because Bun was missing from `PATH` while the Go suite spawns a real broker. - Cleared the weekly SDK advisory gate by overriding the TypeScript SDK's transitive `qs@6.15.1` (GHSA-q8mj-m7cp-5q26) to a patched `^6.15.2`. The override only affects the mutation toolchain; the published client has no `qs` dependency. ### Do not use 2.8.56 - **2.8.56 is broken. Install 2.8.57 or newer.** Its CI run was red — one unit test failed on embedded parent-dependency recovery across a broker restart — so the quality gate blocked the tag, release and image. Only the npm artifact exists, because it was pushed manually while the gate was failing. It is being deprecated on npm as part of this release, and unpublished as well if that happens inside npm's 72-hour window. ## [2.8.56] - 2026-08-03 (do not use) ### Engine correctness - Made durable acceptance fail closed in Embedded and real TCP+SQLite modes. A synchronous SQLite rejection, including a full disk, now leaves no executable, queryable, counted, or identity-owning RAM-only job after single or bulk adds. The broker plans custom-ID retirement, dedup replacement, dependency-completion pins, and parent linkage without destructive mutation; commits the required rows atomically; and only then publishes queue/index state. A rejected completed/DLQ ID reuse preserves the previous generation and result across restart, a rejected parent link exposes no half-edge, and ordered bulk accepted-prefix behavior is unchanged. Deterministic fault injection and real bounded-filesystem regressions cover both transports. - Made deduplication replacement atomic across the heap, `jobIndex`, unique-key ownership, counters, and SQLite. Replaced durable jobs no longer remain queryable or resurrect after a broker restart, and generation-safe cleanup cannot delete the replacement's key. - Made Worker limiter admission atomic at job start. Concurrent and batched workers now acquire the rate token before processor fan-out instead of after completion, so `max`/`duration` is enforced for every concurrency value. - Separated the public job name from user data in the domain model, SQLite, and protocol v3. Embedded, TCP, MCP, list, worker, DLQ, Flow, and all six external SDKs now preserve `job.name` without adding or consuming `data.name`, while schema migrations retain a bounded fallback for legacy name envelopes. - Populated `returnvalue` and `failedReason` consistently on embedded and TCP reads, preserving `null` and every falsy result. Worker retention options now apply remotely, DLQ statistics include `byQueue`, and the async filtered DLQ retry returns the authoritative broker count. - Made completed-job retry an atomic durable generation transition. The broker now resets attempts, progress/message, processing/completion timestamps, and heartbeat and deletes the prior result in the same SQLite transaction before publishing the waiting state. Neither stale `returnvalue` nor completed metadata can remain visible or resurrect after restart; stacktrace and timeline history are intentionally preserved. - Fixed store-and-forward shutdown after a forced connection-pool close. Queued durable commands now settle deterministically instead of failing with `Connection pool is closed` during recovery or teardown. - Made late outcomes from lock-expired `preventOverlap` cron generations idempotent. The engine records the exact retired lease in a bounded map, so embedded single ACK and TCP batch ACK stop retrying after the cron job is deliberately discarded, while wrong tokens, arbitrary missing jobs, and duplicate ACKs against completed jobs remain errors. SQLite deletion and custom-ID reuse retain their existing generation safety. - Made processing timeouts authoritative over every late processor outcome. The timeout transition records the exact `{ jobId, startedAt, token }` while owning the processing claim. A later ACK, FAIL, manual `moveToFailed()`, or sandbox result for that retired generation returns structured ignored evidence and emits no false local `completed`, `failed`, or Worker `error` event. Batch ACK evidence includes exact positions, including duplicate IDs; a retry's current token still applies and wrong/missing tokens still reject. - Replaced the placeholder queue metrics/event APIs with a durable per-queue implementation. `getMetrics()` now returns bounded, newest-first one-minute completed/failed series with real pagination and cumulative counters; `trimEvents()` trims a separate bounded lifecycle journal and returns the exact idempotent removal count. SQLite restart, queue isolation, concurrent batch completion, retries, obliterate, embedded and TCP paths share the same contract. - Fixed `Queue.add({ repeat: { pattern } })` creating zero-delay successors and starving the runtime. Completion-chained repeats now use the authoritative cron parser, preserve timezone/date/window/limit and compatible job policies, apply positive/negative offsets without skipped or past deadlines, retain `updateData()` propagation, and continue across SQLite-backed broker restarts. Interval offsets establish a future first-successor phase without making `immediately` recur. Schema v34 persists repeat and advanced generation policy in `jobs.extended_options`; ambiguous parent/dependency and outer custom-ID combinations are rejected atomically in embedded and TCP modes. - Completed legacy FlowProducer metadata parity. Chain and parent-first tree descendants now persist the exact `__parentId` / `__parentQueue` alongside `__flowParentId`, including cross-queue and restart recovery. Worker, `getJob`, and list reads expose engine-owned `FlowJobData` without reintroducing the historical name envelope. Flow `updateData()` atomically preserves every topology field, rejects reserved-key forgery over embedded and raw TCP paths, and still permits unrelated `__custom` keys on ordinary jobs. - Made `FlowProducer.closing` meaningful and failure-safe: it is `null` while live, becomes the single Promise installed by the first close/disconnect, releases its connection ownership once, and remains stable after resolution or rejection. - Replaced the five-second processing-timeout sweep with an active-job next-deadline scheduler. Short timeouts now fail near `startedAt + timeout` in embedded and TCP modes; concurrent deadlines remain ordered, late ACKs retain retry safety, and timers beyond the signed 32-bit runtime ceiling are chunked without overflow. ACK/FAIL, manual moves, disconnect/stall/lock recovery, cleanup, obliterate, custom-ID generation reuse, and shutdown all invalidate stale deadline entries. - Made mixed FIFO/LIFO ordering total at equal priority: LIFO jobs form a newest-first partition ahead of FIFO jobs, while numeric priority remains the authoritative first key. - Made `Queue.add({ parent: { id, queue } })` and `addBulk()` create durable, atomic dependency edges to existing pending parents in embedded and TCP modes, including cross-queue and restart recovery. Bare protocol `parentId` forward references remain compatible with legacy flow construction. - Preserved complete `SchedulerInfo` values from `upsertJobScheduler()` in both embedded and TCP modes: immediate results now retain `pattern`/`every` and use the exact scheduler or broker `nextRun` value instead of approximating pattern schedules as a 60-second interval. - Made `getJobSchedulers(start, end, asc)` apply its documented list contract in both modes. Results are ordered by next execution time, equal deadlines use scheduler IDs as a deterministic tie-breaker, ranges are zero-based and inclusive, `end: -1` reads the remainder, and `asc` defaults to `false`. - Chunked cron timers at the runtime's signed 32-bit timeout ceiling while retaining the absolute persisted `nextRun`. Yearly and other far-future schedules no longer collapse to a 1ms hot loop or flood overflow warnings. - Unblocked the publish-time TypeScript build by typing MCP cron serialization against normalized domain `CronJob` values. Embedded and TCP MCP backends now expose identical optional cron fields instead of leaking protocol `null` values from TCP operations. TCP creation now reads authoritative nested cron metadata and propagates broker validation errors instead of returning a fabricated success. Dedicated embedded and real-TCP functional contracts now cover invalid input, add/list/get metadata parity, delete and post-delete lookup behavior. - Normalized persisted result lookup at the QueueManager boundary so a missing result is `undefined` while an explicitly completed `null` result remains `null`. Flow reads now preserve every falsy value and omit only genuinely missing IDs in both runtimes. - Enforced lease ownership consistently for ACK, FAIL, result-bearing/bare batch ACK, and every active-state Job move. A present lock now requires its exact token in embedded and TCP mode; processor `Job.changeDelay()` and `Job.retry()` bind that delivery token implicitly despite their tokenless public signatures. Rejected single and batch operations leave job state, result, and ownership unchanged, while unlocked administrative transitions and expired-but-current completions retain their recovery semantics. - Made every processor-owned transition retire exactly one local delivery generation. Successful `retry()`, `changeDelay()`, `moveToWait()`, `moveToDelayed()`, and `moveToWaitingChildren()` now suppress both the later automatic ACK and catch-path FAIL without synthesizing a terminal event or counter. A rejected token still follows normal failure handling. Synchronous `Job.discard()` registers one pending broker settlement before returning; graceful close waits for it, duplicate calls share it, an authoritative no-op is silent, and a real rejection emits one scoped Worker error. `Discard` now carries and verifies the current lease token in Embedded and TCP mode, preventing a stale processor from dead-lettering a newer delivery. Deterministic regressions cover return/throw, close, duplicates, no-op, rejection, and stale-token redelivery in both transports. - Made Worker processing generation-aware. A stall redelivery to the same automatic or manual Worker now starts with a fresh broker token; stale handlers cannot publish outcomes or erase the current heartbeat, lock, cancellation, limiter, or concurrency tracking. Embedded heartbeats now renew the exact current token just like TCP, and processor outcome logic was split into a focused module to keep runtime components below 300 lines. - Fixed manual Worker lease propagation in embedded and TCP modes. `getNextJob()` now exposes a clean `ManualJob` with first-class `name`, typed user `data`, and the broker token; `processJobManually(job)` reuses that tracked token when omitted, while stale job objects cannot claim a newer delivery generation. - Made Worker pause/resume lifecycle ownership idempotent. Paused Workers keep the single lease-renewal and registration heartbeat needed by active and buffered deliveries; `resume()` no longer creates an orphaned interval, and `close()` now permits natural process exit after any number of pause/resume cycles in embedded and TCP modes. - Unreferenced the protocol limiter's opportunistic cleanup interval. The singleton still bounds idle TCP/HTTP client state while a server is active, but handling the first command no longer leaves a stopped broker process alive solely for maintenance. - Fixed both sides of workflow compensation recovery ownership. `recover()` on the same live Engine no longer waits for its own compensation handler and deadlocks the caller. After `Engine.close(true)`, a replacement Engine in the same process still waits for the exact in-flight unwind owner, reloads the authoritative SQLite row through its own store, and retries only when compensation remains owed. Deterministic embedded and real-TCP regressions cover the live-owner and force-close paths, and the minimized command-model seed is retained as replay evidence. - Made the TCP concurrency sweep correctness-gated. It no longer disables lease renewal or stops when processor invocations merely reach the requested count; each sample now reconciles accepted and invoked IDs, duplicate deliveries, authoritative broker terminal counts and Worker errors before publishing throughput. Host, port, scale, cases, heartbeat and timeout are explicit inputs, and the module is import-safe for a real dynamic-port SQLite test. The integrity test now reads the actual assigned listener port and the runner rejects a missing or invalid endpoint, eliminating a false-green path that could target an unrelated local broker on `:6789`. - Fixed client-side TCP frame corruption under high-concurrency backpressure. Every physical connection now preserves partial command writes in order, resumes them on `drain`, bounds the pending byte queue, and discards it on disconnect instead of writing later frames ahead of a missing tail. A real 200-way TCP Worker regression reconciles 1,000 accepted jobs with 1,000 authoritative completions and zero duplicate processor invocations. - Rebuilt the BullMQ comparison runner as focused sub-300-line modules. Both products now keep lease renewal active, reconcile accepted and invoked job IDs, reject duplicate processor calls or Worker errors, and stop timing only after their broker reports every job completed with no nonterminal residue. The runner uses isolated endpoints/run IDs, bounded deadlines, deterministic cleanup, import-safe startup, and natural process shutdown. - Hardened the remaining native TCP benchmark entry points. The comprehensive and push/bulk-delta runners now accept isolated `BENCH_HOST`/`BENCH_PORT` endpoints and explicitly select TCP mode; comprehensive and batch-notify processing reconcile accepted and invoked IDs plus authoritative broker terminal counts before a bounded deadline. The self-hosted runner now binds an operating-system-assigned port. Every Queue and Worker is closed from `finally`, servers are always stopped, entry points are import-safe and exit naturally, and all runner modules remain below the 300-line source limit. Batch-notify also raises its self-hosted completed-job retention to its 100,000-job maximum scenario, so authoritative counts cannot be truncated by the production broker's 50,000-job default. Comprehensive resets its shared Embedded manager after each scale, including error paths, preventing earlier samples from consuming the completed-job retention window used by its 50,000-job sample. Its durable TCP processing deadline is now a validated `BENCH_TIMEOUT_MS` input with a printed 600-second default, allowing slower native SQLite hosts to finish without weakening accepted-ID or authoritative-state conservation. Push/bulk-delta now shuts down its shared Embedded manager from the entrypoint `finally`, allowing natural process exit after the final median and applying the same teardown when any Embedded or TCP sample fails. - Published the [dated v2.8.56 native engineering report](https://github.com/egeominotti/bunqueue/blob/main/docs/benchmarks/native-engineering-2026-08-03.md) with the final Apple M1 Max campaign. Repeated Workflow samples passed persisted integrity at 211–251 workflows/s Embedded and 273–319 workflows/s TCP for the four single-engine scenarios; the tuned 12-instance curve completed at 758/618 workflows/s after the host saturated at eight instances. Queue, batch-notify, TCP serde/sweep, comparison, fix-impact, dependency, event, stress, and million-job diagnostics were rerun after the final release gates with their exact topology and correctness boundaries. The report keeps the durable SQLite-versus-in-memory distinctions explicit and preserves the July Ryzen 9 campaign as the publication-grade capacity reference. - Made expired-lock recovery linearizable. Every candidate is revalidated under the shard and processing write locks against the same processing object, the same lease object, and the current expiry before any recovery budget or state is consumed. Concurrent sweeps can no longer reclaim one generation twice, double-increment attempts/stalls, duplicate notifications, or leave a job in both the waiting heap and DLQ. Terminal expiry now emits the documented `stalled` event before `failed` in embedded and TCP modes. - Unified public Job lifecycle metadata for direct and list queries. Embedded and TCP proxies, reflected properties, `toJSON()`, and `asJSON()` now read the same authoritative attempts, started-attempts, stall count, progress, processing timestamp, and terminal timestamp instead of resetting fields on selected TCP paths. ### Embedded/TCP parity - Added authenticated QueueEvents streaming over the binary TCP protocol with queue filtering, bounded writes, explicit subscribe/unsubscribe commands, dedicated client connections, and automatic resubscription after broker reconnect. TCP Workers now receive the same queue-scoped `stalled` event as embedded Workers. - Made `FlowProducer.getParentResult()` and `getParentResults()` authoritative over TCP while preserving synchronous embedded compatibility, and fixed `Queue.waitJobUntilFinished()` to return the exact result for both an already-completed remote job and an in-flight completion event. - Added authoritative async Bunqueue façade methods for pause/resume, DLQ configuration/reads/retry/purge, and global rate-limit changes. Legacy synchronous snapshot and fire-and-forget forms retain their documented compatibility contract. ### Architecture - Kept the QueueManager, Queue, Worker, SandboxedWorker, transport, persistence, scheduling, Flow, MCP, and server layers split into focused modules no larger than 300 lines. New repeat scheduling, telemetry journal, QueueEvents TCP subscriptions, persistence migrations, Job metadata, DLQ conversion, and Worker outcome/manual-processing responsibilities live in dedicated modules instead of expanding the public façades. - Continued separating contracts from behavior through domain, application, client, transport, and Worker `types/` modules. The queue hot path retains its synchronous lock boundaries and the documented lock order while persistence, protocol conversion, and public-object reflection remain independently testable. - Added explicit SQLite migrations and bounded legacy decoding for the protocol v3 job-name model, persisted repeat policy, and telemetry tables. Existing databases are upgraded in place without rewriting arbitrary user payloads. - Made SQLite schema upgrades fail closed and retryable. Pending DDL, legacy backfills, and the final version record run in one synchronous transaction; only exact duplicate schema-object errors are accepted as idempotent. Any disk-full, I/O, corruption, syntax, or constraint failure rolls back without advancing the version. Migration 6 retains two explicit statement boundaries so an old database with only one cron dedup column repairs the missing column before the current version is recorded. ### External SDKs - Upgraded the TypeScript, Python, PHP, Go, Rust, and Elixir SDKs to negotiate protocol v3 and advertise `separate-job-name`. All producers, bulk producers, schedulers, workers, Job objects, and flow snapshots keep the job name in its wire field and preserve arbitrary user data, with bounded legacy-envelope decoding for older brokers and rows. - Extended the shared conformance runner with name/data round trips, mixed and scalar payloads, scheduler names, legacy decoding, and protocol capability checks. Each SDK also retains a native regression so conformance cannot pass through a driver-only adaptation. - Forwarded Worker lease tokens through active Job mutations where required and preserved authoritative broker results in the language-specific Queue/Admin surfaces. - Made late Worker outcomes broker-authoritative across all six SDKs. An exact timeout or retired-lease no-op no longer emits or counts a contradictory local terminal result; Rust settles the handler attempt without synthesizing terminal state. TypeScript and Python require positional `ignoredIndices` for ACK batches, remain correct with duplicate job IDs, and reject ambiguous or malformed evidence. ### Documentation and verification - Converted all 37 Queue, Worker, Cron, DLQ, and Flow guide pages into 40 real-broker executable files. The combined embedded/TCP guide audit now runs 642 tests and 1,888 assertions with no expected-failure pins. - Added a fail-closed, no-mock core E2E matrix that automatically discovers all 308 callable Queue, Worker, Job, Cron, DLQ, Flow, Workflow, transport and related facade instance methods from TypeScript. It exercises the exact applicable surface against fresh embedded and real TCP SQLite runtimes. A dedicated required CI job now blocks the release graph when any public class or method is uncovered or fails its runtime contract. - Expanded that gate into 580 applicable method/transport checks and a complete 308-row Markdown/JSON evidence matrix uploaded by CI for direct review. - Added shared embedded/TCP script contracts for real QueueEvents lifecycle payloads, Worker stall delivery, exact wait results, falsy/null Flow results, missing-result semantics, queue isolation and subscription teardown. Focused regressions also cover authentication, raw unsubscribe, command/event correlation, and reconnect/resubscribe. - Added shared `skeptic` reviewer profiles for Claude Code and Codex CLI, with repository instructions requiring the review before every commit and push. Repository content is now English-only, and obsolete design plans plus a redundant manual Simple Mode script were removed. - Added fresh disposable OrbStack Machine release gates on the Mac's native architecture. Ubuntu 24.04 is the canonical local Linux gate and Debian 13 checks distribution compatibility, both without host mounts, credentials, or reusable state. GitHub Actions supplies independent native `amd64` coverage; translated Rosetta runs are diagnostic only. - Fixed the cross-runtime documentation fixture teardown order so its embedded manager closes before the temporary SQLite directory is removed. The full guide run no longer carries a false WAL-checkpoint cleanup warning. - Hardened the crash-recovery subprocess harness for parallel isolation by asking the kernel to assign its unused HTTP listener instead of assuming the port adjacent to TCP is free. The expired-lock regression now asserts the explicit broker-authoritative `already-finalized` result. - Gave the publish-build regression a 40-second harness budget while keeping its 30-second build deadline, so a valid fresh Linux build is not terminated by Bun's unrelated five-second default test timeout. - Added deterministic embedded and real-TCP regressions for terminal lock event order, overlapping expiry sweeps, attempts/stall conservation, queue/DLQ exclusivity, and public Job metadata reflection. These tests first failed on the old implementation and now run as ordinary required tests—no `test.failing` or expected-failure pins remain. - Passed the final real TCP/SQLite command model with 10 generated histories and 83,939 invariant assertions. The complete isolated product sandbox passed 8,120 unit tests, 489 TCP integration checks, and 332 embedded integration checks with zero failures; the isolated SDK sandbox passed 611 tests across all six native suites and their shared conformance contracts, with only three declared long-running soak profiles excluded. - Reviewed the sandbox's only telemetry signal, end-to-start RSS growth in the single process loading 582 unit files. Three fresh-process TCP/SQLite chaos soaks, each with continuous worker kills plus final compact/GC checks, passed without job loss, unbounded engine collections, WAL growth, or latency drift. TCP ended below its starting RSS; embedded ended only 26.3 MiB above its start and remained below the anomaly threshold with a 91.0 MiB peak. ## [2.8.55] - 2026-08-01 ### Engine correctness - Preserved DLQ automatic-retry history, retry count, expiry and backoff across repeated terminal failures and broker restarts. SQLite now moves each retried generation from `dlq` to `jobs` atomically and durably removes capacity-evicted entries. - Classified processing timeouts as `timeout` through retry history and the final DLQ entry without changing the public failure API or contaminating a later explicit processor failure. - Wired the cloud `s3:backup` command through the live server context so a requested backup reaches the configured backup manager. - Forwarded custom rate-limit durations from the TypeScript and Python SDKs, restoring the requested limiter window instead of silently using the broker default. ### Architecture - Split QueueManager, Queue, Worker, SandboxedWorker, TCP transport, SQLite, server routing, scheduler, domain structures, MCP, CLI and benchmark logic into focused implementation modules while retaining their stable public façades and behavior. - Moved public and internal contracts into dedicated `types/` modules. Every runtime TypeScript source file is now at most 300 lines, with automated gates for the ceiling, façade size, type-module presence and documentation links. ### Documentation and verification - Audited all 33 requested Queue, Worker, Cron and DLQ guide sections and added discoverable real TCP and embedded evidence for every section. Shared parity contracts now cover DLQ maintenance, stall detection, queue groups, namespaces, rate-limit windows, timeouts and worker lifecycle. - Replaced permissive integration assertions with exact state, count, ordering, retry and lifecycle checks, including real TCP SandboxedWorker execution and the actual 60-second DLQ maintenance timer. - Completed every audited multi-language example group with Bun, Node.js/Deno, Python, PHP, Go, Rust and Elixir tabs, guarded by an executable documentation test. ## [2.8.54] - 2026-08-01 ### Public API completeness - Completed 39 previously exposed Queue, Worker, QueueGroup, dependency, DLQ, deduplication, retry, limiter and state-query methods or method families that returned sentinels, ignored public options or behaved differently between embedded and TCP runtimes. - Wired every one of the 32 non-serialization methods exposed by a DLQ `Job` to live queue operations. Deduplication-key release is generation-safe, and explicit waiting-children transitions now survive SQLite restart. - Made unbounded state reads exhaustive beyond the first page, preserved ascending and descending ordering, and returned authoritative prioritized and waiting-children counts without changing the wire protocol. ### Verification and SDK audit - Added a dedicated 135-test regression contract with generated properties, embedded coverage, real TCP broker coverage and one end-to-end test for each DLQ `Job` operation. Lifecycle and persistence changes also pass the asynchronous command model and the complete isolated sandbox. - Documented the core-parity audit for all six network SDKs. None currently exposes the complete network-capable core surface; the per-language method and semantic gaps are now recorded as an explicit implementation backlog. ## [2.8.53] - 2026-07-31 ### Fixed - The TCP protocol audit now asks the kernel to bind an available port and reads the assigned listener port before connecting, eliminating the `EADDRINUSE` race that failed the parallel CI unit gate. - A focused regression now rejects pseudo-random high-port selection in this real TCP audit and requires the atomic `port: 0` listener contract. ## [2.8.52] - 2026-07-31 ### Fixed - The binary-build workflow now quotes the GitHub Actions command-file path used for version outputs, resolving the `SC2086` failure reported by Actionlint when ShellCheck is available on the CI runner. - The release-graph regression suite now rejects unquoted redirections to GitHub command files, so this class of workflow failure is detected by the regular unit and sandbox suites even on developer machines without ShellCheck. ## [2.8.51] - 2026-07-31 Production hardening for atomic cross-SDK flows, dependency completion durability and fail-closed release gates. The release adds executable invariant, mutation and real-broker E2E coverage for every official SDK while preserving the existing wire protocol. ### FlowProducer and SDKs - All six external SDKs now preallocate complete flow graphs and commit them with one broker-side `PUSHF` command. Tree, bulk, chain and fan-in builders validate unsupported lifetimes, caller-owned topology, reserved metadata, duplicate IDs and the broker's returned ID/queue snapshot before exposing a result. - Legacy SDK `UpdateParent` calls remain compatible when a declared child finishes before the backpatch. Completed, active, failed and `removeOnComplete` races update only the child's ownership; parent topology and scheduling never transition twice. Durable DLQ data and the `flow_failures` outbox are re-keyed transactionally, retaining the original failure reason across restart. - Each SDK now runs deterministic, shrinkable generated invariants with its native property framework: fast-check, Hypothesis, Eris, Rapid, proptest or StreamData. Separate pinned mutation campaigns challenge the pure planners and committed-snapshot validators with StrykerJS, mutmut, Infection, Gremlins, cargo-mutants and Muex. - Every SDK carries a language-specific invariant reference and contributor instructions. Its README includes runnable atomic-flow examples and the exact property, mutation, real-broker E2E and isolated sandbox commands. - Flow dependency promotion now checkpoints state/timeline before workers are notified. `removeOnComplete` ACK, optimized ACKB and late stall ACK paths atomically replace the removed child with a payload-free SQLite completion proof. Recent unreferenced proofs remain FIFO-bounded; proofs owned by live waiting parents are pinned until the final reverse edge is durably released. Recovery reconstructs ownership before pruning—even when the configured cap shrinks—never trusts an orphan result row as completion, preserves already-promoted parents after proof eviction, and prevents a reused custom ID from inheriting an older generation's completion. ### CI/CD - The six-language SDK workflow is now a reusable, fail-closed dependency of the main quality gate. Version checks, binaries, container publication and GitHub releases cannot run unless core, documentation and every SDK job succeeds; a structural regression suite mutation-checks each release-DAG edge. - TypeScript SDK publication is an explicit manual workflow with a requested version, current-`origin/main` enforcement, frozen lockfile, package-content validation, provenance, preflight registry/tag checks and tag creation only after `bun publish` succeeds. - Scheduled/manual SDK mutation jobs use pinned runtimes and mutation engines; the ordinary SDK gate continues to run the faster generated properties on every release-capable change. ## [2.8.50] - 2026-07-30 Production hardening for the experimental workflow engine and `FlowProducer`. Workflow runtime changes remain isolated under `src/client/workflow/`. Flow creation now has a broker-side `PUSHF` primitive and schema version 27; external SDK source code is unchanged and remains wire-compatible. ### Fixed - **Retries now remain bounded across crash recovery.** Attempt counts are cumulative for a step occurrence instead of restarting when its node is re-entered, so a repeatedly recovered step cannot exceed its declared `retry` budget. - **Workflow timeouts accept every `PromiseLike` and remain correct beyond the platform timer ceiling.** Long deadlines are armed in bounded chunks rather than overflowing a 32-bit timer, and timed-out handlers receive an `AbortSignal` so cooperative downstream work can stop. - **Signals are atomic and first-writer-wins.** Recording the payload and claiming the parked run happen transactionally; concurrent or repeated deliveries cannot replace the accepted payload or enqueue two resume chains. A failed enqueue restores an actionable persisted state instead of losing the approval. - **Crash recovery no longer loses or duplicates sub-workflows.** A restarted parent adopts its persisted child, republishes missing work without creating a second child, and keeps the original child deadline rather than granting a fresh timeout window. Orphaned children become independently recoverable only after their owner is gone. - **Branch, loop, map and sub-workflow decisions survive replay.** Chosen paths, iteration inputs, item snapshots and child identity are journaled before dispatch, so non-deterministic callbacks are not re-evaluated after a crash. - **Definition drift now fails closed.** Registered workflows carry a deterministic definition hash plus an explicit revision. A persisted execution cannot be resumed under a renamed or structurally different graph and silently run the wrong node. - **Failed executions are recovered through the unwind before any forward work is admitted.** Per-step compensation outcomes remain exactly-once, a write failure leaves an operator exit, and duplicate recovery cannot re-run a settled reversal. - **Abandoned nested rollbacks stay terminal.** Resuming a parent no longer reopens a child explicitly abandoned as `failed`/`stuck`, so its compensators cannot run again after the operator accepted a partial rollback. - **Map nodes persist honest per-item outcomes.** Running, completed and failed states, results, errors and lifecycle events are recorded without replaying successful items; compensation receives the matching item and result in reverse completion order. - **Execution listing is deterministic.** SQLite applies filtering, total ordering and pagination in one query with stable tie-breakers and supporting indexes, eliminating duplicate or skipped rows on an unchanged result set. - **Enqueue failures no longer strand unreachable executions.** A start that cannot publish its first node removes the newly inserted row; later publications preserve a recoverable cursor and error instead of handing back a run that can never advance. - **Workflow identifiers are collision-resistant in production.** Real execution IDs use 128 bits of CSPRNG entropy; deterministic simulated-clock entropy remains isolated to tests and replay campaigns. ### Changed (experimental API) - Registration validates the complete graph: reserved/internal names, duplicate branch paths, loop namespaces, unsupported inline node kinds and unsafe numeric bounds are rejected before a run can start. - `retry`, timeout, iteration and pagination options require finite safe integers with contract-specific bounds. Inline builders accept executable steps only. - Sub-workflow polling and timeout are configurable. Expiry fails the parent but does not claim to have forcibly cancelled a still-running child. - Workflow event types, execution/step records and public node definitions are exported as named types. Source responsibilities were split into files below the repository's 300-line limit without changing the import path. ### FlowProducer - **Every Bun flow creation API is now atomic.** `add`, `addBulk`, `addChain`, `addBulkThen` and `addTree` preallocate the final IDs and send one `PUSHF` graph. Validation and ownership checks finish before mutation; all affected shards are locked in order, and configured SQLite commits every row before a worker can observe a leaf. - **Malformed or oversized graphs fail closed.** The client and broker enforce bounded jobs/depth/data, strict runtime wire types, reserved metadata, duplicate/missing/asymmetric edges, cycles, mutually exclusive failure policies and unsupported repeat/dedup/debounce lifetimes. Topology validation and committed-snapshot indexing are O(V+E), including wide 10,000-job flows. - **Terminal child policy is restart-safe.** All four failure flags persist on jobs, and the new `flow_failures` outbox commits with the terminal child. Recovery applies fail/remove/ignore/continue idempotently before workers start; queryable failure values live until the parent terminates. - **Manual dependency removal is a real detach.** Parent dependencies/children, child ownership/metadata, reverse indexes, protected results and both SQLite rows transition together. An active child detached this way cannot later fail its former parent. - **Flow Job and traversal APIs now expose authoritative state.** TCP errors, readiness errors and lock-extension errors are thrown; object progress uses the canonical numeric/message wire shape; serialization no longer leaks internal metadata. `getFlow` honors zero bounds and rejects missing, cyclic, malformed or cross-linked descendants instead of returning a partial tree. - **Identifier tombstones cannot resurrect dependencies.** A retained completion/result/timeout or an ID still referenced by a waiting parent rejects reuse, preventing a removed prior generation from making a new child look completed. - **SQLite initial state now matches the scheduler.** Durable single, batch and flow inserts retain `prioritized` and `waiting-children` instead of flattening both to `waiting`, keeping recovery diagnostics and persistence invariants coherent with the public state. ### Documentation and verification - Rewrote the internal workflow reference and all nine public guide pages around the actual durability contract: at-least-once external effects, one engine per process, live (non-replayed) events, first-writer signal semantics, offset pagination, child ownership and the difference between timeout and cancellation. - Corrected and expanded the quickstart, approval, rollback, AI-agent and SDK examples. Provider effects use stable idempotency keys, startup calls `recover()`, durable state is polled explicitly, and rollback examples reconcile ambiguous provider outcomes. - Added package-backed tests for every offline documentation example and strengthened weak workflow assertions so they check exact ordering, per-item rollback, branch output, archive boundaries and emitted events. - Added executable FlowProducer guide tests for the Quick Start, chain, fan-in, parent-first tree, queue defaults, bounded traversal and failure-value APIs. - Expanded the fast-check command model with generated workflow graphs and operator histories. It now checks definition identity, legal transitions, no loss/resurrection, exclusive delivery, bounded retry/timeout behavior, branch and loop decision stability, child ownership, map outcomes, compensation exactly-once, deterministic pagination and recovery idempotency against real SQLite and a TCP broker. - Added a separate Fast-Check FlowProducer graph model plus realistic dynamic-port TCP/SQLite E2E coverage for three cross-queue workers, exact-once execution, child-first ordering, failure metadata and broker restart. ## [2.8.49] - 2026-07-30 Documentation site only: five responsive layout defects and the SEO gaps found by auditing all 83 pages at 390 / 834 / 1024 / 1440px (332 measurements) plus the production build's 319 HTML files. No library code, no runtime behaviour, no published package contents changed. ### Fixed - **Vercel rejected the documentation deployment before Astro could start.** Explanatory text for the two `X-Robots-Tag` routes had been encoded as synthetic `"//"` properties inside `vercel.json`; Vercel's schema rejects unknown header-rule keys. The rationale now lives in the technical architecture reference, the deployed JSON contains only supported fields, and a regression test guards the boundary. - **The "On this page" table of contents was truncated at every desktop width.** Starlight sizes the TOC column as `sidebar-width + (100% - content-width - sidebar-width) / 2`, which assumes a capped `--sl-content-width`; ours is `100%`, so the term went negative, the column collapsed to 9rem and the fixed panel (`width: 100%`, i.e. 100% of the viewport, offset by its static position) ran past the right edge. Every entry lost its tail — 61px at 1152px, 93px at 1280px, 121px from 1440px up, measured identically in a real 1888px window. The TOC now gets a real `--sl-sidebar-width` column and the main pane gives back the same width, so the two flex items still total 100%. - **The header pushed its own controls off-screen between 800px and 1056px.** Above 50rem Starlight swaps the menu button for the sidebar, so the header grid has to fit title + search + the entire right group (nav links, socials, theme select, CTA — 581px intrinsic) beside an 18rem sidebar column that cannot shrink. On iPad portrait (834px) 209px of that group sat outside the viewport: no theme toggle and no "Get started" at all. At 1024px (iPad landscape, 1024-wide laptops) the CTA was still cut through the middle. The four secondary nav links now step aside in that band; all four remain in the footer. - **Body text sat 3px from both screen edges on phones.** The phone layer zeroes the content panel's inline padding so cards, code blocks and the terminal can run edge to edge, and `.bq-wrap` handed the gutter back — but 81 of 83 pages open with a `.bq-hero` and then continue in plain markdown, which is not inside `.bq-wrap`. Prose, headings, lists and tables were left with a 3px gutter while the hero above them had 19px. The text flow now carries its own 1rem gutter; the full-bleed blocks are deliberately untouched. The footer (logo, link columns, legal line) had the same problem and now matches. - **Tab strips squashed instead of scrolling on tablet portrait.** The rule that keeps tab labels intact was scoped to `max-width: 40rem`, but the strip is starved wherever the content column is narrower than its natural width — which also happens from 800px to 1000px, where the sidebar cuts the column to 472-598px against a 619px strip. Labels shrank below min-content and wrapped letter by letter ("B/u/n" over three lines, 70px tall instead of 29px) on 19 pages. The rule is no longer breakpoint-scoped. - **The simulator page scrolled sideways between 965px and 1120px.** `.sim-grid` collapsed to one column only below 960px, while the rule that widens the content column to 88rem starts at 72rem. In the gap the viewport looks roomy but the docs sidebar leaves ~664px, so the `290px + 1fr` grid (plus the nested `1fr 1fr` row) overflowed the page by up to 120px — and the fixed sidebar then covered the shifted text. The collapse breakpoint now meets the widening rule exactly. - **Every page carried two `

` elements.** Starlight renders the frontmatter title as the page `

`; on the 81 hero pages custom CSS hides the panel containing it, so the keyword-bearing heading lived in a `display: none` subtree while the hero supplied a second `

`. A `PageTitle` override keeps the real `

` on pages that actually display it and downgrades it to a `
` (same `#_top` anchor, which the TOC links to) where the hero already owns the heading. `architecture/model-based-testing` also had a redundant `# ` heading duplicating its own title; removed. - **The simulator skipped from `

` to `

`.** Its nine panel titles are now `

`, so the page no longer breaks heading order. - **Six meta descriptions were long enough to be truncated in search results** (up to 233 characters). All 83 are now ≤160. ### Changed - `X-Robots-Tag: noindex, follow` for the versioned TypeDoc dump under `/reference/v/`. Those 236 static pages are 74% of the crawlable surface and carry no canonical, no per-page description (all 236 read "Documentation for bunqueue"), duplicate titles and a 93-word median. They stay crawlable and linkable for humans and drop out of the search index — which also defuses the version-churn trap, since a bump to the next reference path leaves no indexed URLs behind to 404. The per-page markdown twins (`*.md`, for AI crawlers) get `noindex` for the same duplicate-content reason; `llms.txt` and `llms-full.txt` are unaffected. - Article structured data now carries a real `datePublished`, taken from each page's first commit, alongside the existing git-derived `dateModified`. Blog posts keep their frontmatter publication date and take `dateModified` from git rather than repeating the publication date. - `twitter:image` (and its alt text) is now emitted on every page. X fell back to `og:image`, but the Card Validator checks for the explicit tag. ## [2.8.48] - 2026-07-30 Three CI failures fixed, each of which could only ever fail in CI. No runtime behaviour changed: this release touches `.gitignore`, two workflows, `scripts/`, `test/`, documentation, and `package.json` (version plus one new script entry) only. ### Fixed - **The docs site could not build from a clean checkout.** `.gitignore` excluded `data/` for runtime SQLite directories, and that pattern also matched `docs/src/data/`, so the generated `apiVersions.json` that `reference.mdx` imports was never committed. Local builds succeeded from the file on disk; CI failed with `Could not resolve "../../data/apiVersions.json"`. The ignore rule now carries an explicit `!docs/src/data/` negation (a directory negation — git cannot re-include a file inside an excluded directory) and the file is tracked. - **The weekly Go SDK soak had never once completed.** `go test` panics at its own 10-minute default, which is shorter than the 15-minute soak profile, so the job died at `panic: test timed out after 10m0s` every week regardless of client behaviour. - **The weekly Elixir SDK soak died 60 seconds in.** ExUnit kills a test at 60 s by default: `** (ExUnit.TimeoutError) test timed out after 60000ms`. Only the Elixir 1.20.1 leg was affected because the soak step is gated to that matrix entry. - **"all versions" linked to the wrong place on 234 API-reference pages.** `scripts/build-api-reference.ts` called its two-parameter `banner()` with three arguments, so the depth parameter received a boolean, collapsed to `0`, and every page below the version root linked back to that version instead of `/reference/`. The link is now derived by `allVersionsHref(depth)`, the already-published pages were repaired in place, and `test/build-api-reference.test.ts` pins the arithmetic and the signature. `scripts/` is outside `tsconfig.json`'s `include`, which is why a three-argument call to a two-parameter function shipped in the first place. Both bounds are now derived from `BUNQUEUE_SDK_SOAK_SECONDS` plus 300 s of slack for broker startup and teardown, so raising the soak duration cannot silently reintroduce either failure. The expansion uses `${VAR:?}`: under GitHub's default `bash -e` (no `set -u`) an unset or renamed variable would otherwise expand to a 300-second bound — tighter than the default it replaces — and fail in exactly the way being fixed. ### Changed - **The SDK soak profiles can now be run on demand.** `.github/workflows/sdk.yml` gains `workflow_dispatch` with a `run_soak` input; previously the soak steps (and the Go native fuzzing step, now gated the same way) ran on `schedule` alone, so a fix to them could not be exercised before the next Sunday. `RATE_LIMIT_MAX_REQUESTS` is derived from the same condition as the soak gate — a manual soak run with the push-level limit would measure the broker's anti-abuse throttle instead of the client. - **Every SDK job now has a `timeout-minutes` bound** (45, or 50 where a soak and fuzzing share the job). They previously inherited the 360-minute runner default, so a wedge outside any test framework — dependency resolution, a broker that never binds, a hung conformance driver — burned six hours of runner time. ### Added - **`bun run check:docs-data`** (`scripts/check-docs-data.ts`), wired into the CI docs job ahead of the build and into `bun run check`. It asserts that every relative module specifier (`from`, side-effect `import`, dynamic `import()`, `require`) and asset reference (markdown image, `src=`) in `docs/src/content/docs/**` resolves to a git-tracked file, and that the committed `apiVersions.json` still equals what the generator would derive from `package.json` and `docs/public/reference/` — including that the tree for the current version is itself tracked, or the published listing would link to a 404. All of these are invisible locally: an ignored import resolves on the author's disk, and a stale version list still builds. Fenced and inline code is stripped before scanning, so a page that documents a relative import in a sample is not mistaken for one. A `dev` entry is rejected outright so a local `--dev` preview cannot be published. Note that a **minor** bump now fails the check until `bun run docs:api` output is committed; patch bumps are unaffected. Scanners are unit-tested in `test/check-docs-data.test.ts`. `docs/src/components/**` is scanned as well, since a component importing an ignored file fails the build identically, and every existing extension candidate for a specifier is checked rather than the first, so a stale untracked `x.js` cannot hide behind a tracked `x.ts`. - **`test/sdk-ci-workflow.test.ts` now asserts the soak invariants instead of a literal env string.** It parses `sdk.yml` and requires that `RUN_SOAK` and `RATE_LIMIT_MAX_REQUESTS` derive from the same condition, that all seven soak/fuzz steps share the gate, that the Go and Elixir soaks carry the duration derivation and its preconditions, and that every job bounds its own runtime. ## [2.8.47] - 2026-07-30 Saga rollback becomes trustworthy: it now covers the steps it used to miss, refuses to claim work it did not undo, and parks for an operator instead of failing quietly. Everything shipped here is in the **experimental** workflow engine (`bunqueue/workflow`). Queue, worker, cron, flows and the wire protocol are untouched: no runtime file outside `src/client/workflow/` changed, and nothing in the core imports that module. The test-gate entry below is the one exception, and it ships nothing: it is in `scripts/`, which is not part of the published package. ### Fixed - **A `doUntil` or `doWhile` iteration that failed after moving money was dropped from the rollback entirely.** The per-iteration record was written only after the body returned, so the turn that threw existed under the bare loop name alone, and that bare name is deliberately excluded from the unwind because it mirrors the last iteration and compensating it too would undo that iteration twice. A loop that charged on every turn and failed on turn 2 refunded turns 0 and 1, left turn 2's charge standing, and still reported `rollbackStatus: 'completed'`. The failed turn is the one MOST likely to need undoing: a charge that reached the provider and then lost the response is recorded failed while the money has already moved. The record was unreachable even by `abandonCompensation`, which walks the same set, so no operator action could give it an outcome. `forEach` was never affected. - **A sub-workflow that failed, parked or timed out was dropped from its parent's rollback.** The `sub:` record was written `running` before the wait and `completed` only on success, so every other outcome left it in flight, and the unwind skips anything that is neither `completed` nor `failed`. A parent whose child was parked with stock still reserved reversed its own steps, reached the end of the pass and reported a clean rollback. The record is now settled `failed`, the parent inherits the child's park, and `resumeCompensation()` on the parent reaches the child. - **`abandonCompensation()` left a renamed step with no outcome at all.** Two gates decided eligibility and disagreed: the unwind kept a record whose definition had vanished but which ran with a handler, and the abandon path re-decided from the definition alone and walked past exactly that record. The run then ended terminal with a step owed a reversal it will never get, in the function that exists to discharge "exactly one outcome per eligible step, never zero". - **One failing database write mid-rollback closed both operator exits and armed a duplicate reversal.** The per-step write sat outside the error handling, so a `SQLITE_BUSY` escaped the whole pass and left the run `compensating`: `resumeCompensation` and `abandonCompensation` both require `compensation-stuck`, so neither was available, while recovery does pick `compensating` runs up and re-drove the pass, running the reversal whose outcome never reached disk a second time. The pass now stops at the first write it cannot persist, parks the run so it stays actionable, and still reports the original write error. This covers the `compensating` transition write as well, which happens on every unwind and had the same hole: nothing had been undone yet, and the run was left with no operator exit and outside the range recovery looks at. - **A parent could roll back a sub-workflow that was still running.** A child that outlives the 300 second poll ceiling makes the parent's step fail while the child is very much alive, and the parent then rolled it back underneath its own forward steps: two writers on one row, compensate handlers interleaved with forward progress, and a child free to reach `completed` with its undo already done. A child that has not stopped is now refused, and the parent parks with a reason that says so. Resolve the child, then resume the parent. - **A store that refused the failure write replaced the step's real error.** Reported for any step, and separately inside loops, where the write ran in a `finally` while the step's exception was propagating and an exception from a `finally` supersedes the one in flight. Either way, "provider timeout after the charge settled" was recorded as "SQLITE_BUSY", and that message is the whole account of what went wrong. The step's own error now wins in all three places, and a write failure with no step error behind it still surfaces instead of being swallowed. - **`retry: 0` was accepted and produced a TypeError as the failure reason.** `retry` is the number of attempts, so zero never ran the body, and the code after the retry loop read a record that was never written: the run's `failureReason` became `undefined is not an object (...)`, where an operator looks for what happened. In a resumed loop it wrote a `failed` record for a handler that was never called, which the rollback then reversed. It is now refused where it is written, by `step()` and by `forEach()`, with a message that says what to write instead. - **An unwind that could not record its own first write still decided outcomes.** The vanished-step check runs ahead of the halted check, by design, so a renamed step was marked `compensation-failed` and announced with an event in a pass where no handler ran and the store had refused everything. The in-memory outcomes then disagreed with a disk that had received nothing, and the event pointed at the wrong cause. - **The isolated test gate reported `passed: true` in `summary.json` for a run that observed nothing.** The markdown verdict and the process exit code already refused a suite that exited 0 with zero tests counted; the machine-readable artifact, which the handoff process is told to read, used a different predicate and disagreed. All four readers, including the SDK gate and the baseline comparison, now share one. - **A deploy that renamed a step destroyed the record of a reversal that had already SUCCEEDED.** The check that halts on a vanished step fired on any record carrying an outcome, including `compensated`, and the unwind then wrote `compensation-failed` over it and emitted a matching event. An operator acting on that record releases the same stock twice, the pass halts there so the reversal that actually failed is never retried, and the ordering is deterministic, so every later resume halts in the same place. Only a reversal that failed, or one that was owed and never reached, halts now. - **A renamed step that had not been reversed yet was dropped from the unwind.** Nothing distinguished "never owed a reversal" from "owed one and the handler is gone", so the run reported a clean rollback over work nobody undid. A step record now remembers whether it declared a `compensate` handler when it ran. - **`resumeCompensation()` on a nested saga was a silent no-op that resolved successfully.** The retry was not forwarded to the child, so the child halted on its own failed reversal, the parent re-parked, and the call returned cleanly having done nothing. The guide names that exact call as the way out of a parent that inherited its child's park. - **A child parked mid-rollback held its parent for the full 300 second poll**, which then reported a timeout: the wrong diagnostic for the one scenario this module exists for, and a worker slot held for five minutes to produce it. The parent now stops at once with the real reason. - **`rollbackStatus: 'not-started'` was documented in three places and never assigned.** A dashboard rendering the documented values showed blank. The field is absent until an unwind is attempted, and the type and the docs now say so. - **A `parallel()` group that broke in two places recorded one cause.** The `AggregateError` carried every failure, but the persisted `failureReason` took only the first message, so an operator read one problem and went looking for a single cause that was not the only cause. It now reads `2 failures: card declined; warehouse offline`, and a lone failure still reads as itself. - **A reversal that failed was walked past on the next pass, and the run then declared a clean rollback.** The unwind decided whether to stop from the failures of THIS pass only, so a record carrying `compensation-failed` from an earlier one was read as already settled and skipped. Reaching a second pass takes only a crash while an unwind is in flight: the row stays `compensating`, recovery drives it again, and it ended `rollbackStatus: 'completed'` with a reversal still sitting in `compensation-failed`. An unresolved failure now stops the chain exactly as it did the first time. `resumeCompensation()` is the one exception, because that is what it asks for. - **A restarted parent started a SECOND sub-workflow child and abandoned the first.** The node started a child unconditionally instead of resuming the one it had already started, and re-entering the node is routine: a restart plus `recover()` re-enqueues the parent's current node. Measured across one restart, the child ran twice, so the work was duplicated rather than merely leaked, and both rows sat `running` forever since a child is excluded from recovery while its parent exists and cleanup only reaps terminal states. The parent now claims its child before waiting and resumes it. - **`resumeCompensation()` no longer destroys the record it is retrying.** It used to clear the failed outcome and persist that wipe before running anything, so a resume that then met a failing store left a durable row with the diagnostic gone and the run marked `compensating`, to be re-driven at every startup. The retry is now asked for with a flag, so nothing is destroyed and the deep snapshot, restore path and second write that could mask the original error are all gone with it. - **`recover()` counted work it had not done.** A node already in flight was re-enqueued and counted as recovered, though the admission check then rejected the job. It is now consulted first, so the returned counts describe what actually happened. - **A deploy that renamed a step made a parked unwind report a clean rollback over an unreversed charge.** Three things compounded: the failure record was wiped on the way into `resumeCompensation`, the step was then dropped from the unwind set because its definition no longer resolved, and with nothing left to halt on the unwind reached its end and wrote `rollbackStatus: 'completed'`. Measured: the operator saw green with zero refunds executed and no record of the failure they had been acting on. A settled record now stays in the unwind set even when its definition is gone, the unwind halts on it, and the reason names the missing step. - **A compensate handler that threw a structured error recorded `[object Object]`.** `String(err)` was applied before the diagnostic was persisted, and a `throw { code: 502, detail: ... }` from an HTTP client is ordinary. The result was stored on a run parked in `compensation-stuck`, the state that exists so an operator has something to act on, and it said nothing about a refund that had not gone through. Non-Error throws are now described, with the class name as a fallback when nothing serialises. - **A duplicate execution id silently overwrote a live run.** The insert was `INSERT OR REPLACE`, so a collision replaced an execution rather than failing. Ids carry a random component, which makes it vanishingly rare against the real clock and reachable under a seeded simulation. It is now a plain `INSERT`: a lost execution is the worst presentation of a collision, a constraint error is the best. - **An approval gate named after an inherited member opened by itself.** `exec.signals` is a plain object used as a map and presence was asked with `in`, which walks the prototype chain: `'toString' in {}` is true. A run shaped `.waitFor('toString')` was resumed the instant it parked, with nobody having signalled anything, and the step behind the gate ran. `constructor`, `valueOf`, `hasOwnProperty` and the rest behaved the same, and an event name read from config or user input is attacker-influenced. Presence is now asked with `Object.hasOwn`. Found by the new generated-input suite, which tries event names a human would not think to write. - **Schema `parse()` output was discarded, so coercion silently did nothing.** `inputSchema`/`outputSchema` are documented with Zod, and `parse()` is the coercing entry point of every such library: `.default()` fills gaps, `.transform()` rewrites, `z.coerce.date()` builds a Date from a string. The engine called `parse()` for its throw and threw the return value away, so a step declaring `.default('EUR')` validated fine and ran with no currency, and the next step read the raw value. The parsed value is now what the run carries forward. A validator that returns nothing still works: `undefined` means "assert only" and the original value is kept. - **`signal(id, event)` with no payload no longer does nothing.** `payload` is optional, so the most idiomatic human-in-the-loop call, "the approver said go", nothing to carry, was recorded as `signals[event] = undefined`. The codec runs with `structuredClone: true` and round-trips `undefined` faithfully, so the key was present but the value was not, and every presence test asked `signals[event] !== undefined`, which is a value test. The engine's two halves then disagreed: `record()` claimed the resume and re-enqueued the node, and the `waitFor` it resumed into was told no signal had arrived and parked the run again. With no timeout the run waited forever after being approved; with a timeout the approval was converted into a timeout **failure**, compensating work the approver had just authorised. Presence is now a key test (`hasSignal`, `event in signals`) at all four decision points, `storeSignals.park`, the `waitFor` pre-check, the `waitFor` timeout re-read, and the crash-recovery resume. An explicit `null` payload always worked and still does. (`workflow/storeSignals.ts`, `waitFor.ts`, `recovery.ts`, `test/repro-workflow-signal-no-payload.test.ts`) - **A parked `waitFor` no longer keeps the process alive.** Clamping long waits to `setTimeout`'s 32-bit ceiling turned a fires-immediately bug into a real 24.8-day handle, so a process whose only remaining work was a parked approval gate never exited, even after `close()`. Timers are unref'd, and `Engine.close()` releases them. - **`abandonCompensation` left sub-workflows with no outcome.** It decided eligibility with `findStepDef()`, which walks step nodes only, so every `sub:` record finished an abandoned unwind with `compensation === undefined`, contradicting the documented "exactly one outcome per eligible step, never zero". - **A compensation could run twice.** Both the in-flight case (`recover()` over a live unwind) and the sequential one (`recover()` driving a parent whose child it also holds a stale snapshot of) re-dispatched handlers that had already run: a refund issued twice, with no trace in the final state. - **A duplicate node job re-ran the node and every node after it**, giving one execution two independent advance chains, doubled side effects, and a final state of `completed` that hid it. `recover()` on a live engine is the reachable path, since it re-enqueues the current node of every running execution. A node now runs under an in-flight claim, and a job for a node the run has already left is ignored. - **Every iteration of `doUntil` / `doWhile` is now compensated, not only the last.** Loop bodies were matched by exact name, so `turn:0`, `turn:1` and the rest resolved to nothing: a loop that charged a card once per iteration issued exactly one refund. - **Each loop iteration's compensate handler sees its own result.** The bare step name mirrors the last iteration, so every handler read the final value: three charges produced three refunds of the third one. - **A step whose name merely contains a colon is no longer treated as a loop iteration.** A step called `charge:extra` alongside a loop body called `charge` was resolved to the loop's definition, so the loop's rollback ran twice and its own never ran. Only a numeric `:` suffix, the one this engine generates, is structural, and the match is anchored: a loop body named `charge:extra` produces `charge:extra:1`, which is an iteration of `charge:extra` and not of `charge`. Unanchored, that record ran `charge`'s reversal four times, never ran its own, and still reported `rollbackStatus: 'completed'`. - **A wedged compensate handler hung the run forever.** Handlers are bounded by the step's `timeout`, like the forward path. Previously one that never settled left the run in `compensating` rather than `compensation-stuck`, so no parked run existed for an operator to resume or abandon. - **A sub-workflow past its own `.pivot()` was reported as compensated.** Nothing of a committed child is undone, so the parent now parks instead of recording a rollback that provably did not happen. - **Recovery drove a sub-workflow child on its own, so its rollback ran twice.** A child started by `subWorkflow` is a row like any other, and recovery selected purely on state, so it picked the child up as a top-level run and drove it behind its parent. Its steps re-ran, the fresh records carried no compensation outcome, and the "never twice" guard therefore did not fire when the parent later unwound that same child: the reversal was dispatched a second time against a provider already refunded. A child now records the parent that owns it and is left to it, unless the parent row is gone. Found by the state-machine model, seed `1267197984`. - **A failed `resumeCompensation()` made the next one run a reversal twice.** The operator retry snapshots the step records and handed the whole snapshot back if the attempt threw, which also erased the reversals that had SUCCEEDED before the throw. The run parked looking untouched, so resuming again refunded twice. The restore now merges: it gives back only what the attempt left unsettled. - **An unwind with nothing eligible left the run non-terminal on the recovery path.** Only the `runNode` caller set the final state, so a persisted `compensating` run whose steps no longer resolve, after a deploy renamed one, came back from `listRecoverable()` at every startup and was re-driven forever. - **`timeout: 0` left a reversal unbounded.** That is the documented way to say "no bound" on the forward path and stays so, but an unbounded reversal holds the engine's in-flight claim, locking the run out of `recover()`, `resumeCompensation()` and `abandonCompensation()` for the life of the process. A reversal now falls back to 30000 ms. - **`cleanup(0)` and `archive(0)` archived nothing when a run had just finished.** Both filtered with a strict `updated_at < cutoff`, and with a zero max age the cutoff is the current millisecond, which is exactly where a just-completed run sits. The cutoff is now inclusive, so the documented "flush everything terminal" call does that. - **`SQLITE_BUSY` could surface from `signal()`.** The engine hands the same data path to the workflow store and to its embedded queue, two connections on one file, and the store had no `busy_timeout`. It is now 5 s. ### Added - **The two decisions where every rollback and duplicate-execution defect lived are now pure functions**: `unwindPlan.decideUnwindAction` (what to do with each record of an unwind) and `admission.decideAdmission` (whether a node job may run). The impure loops that surround them became dispatchers. Both were previously buried in async methods that also read SQLite and emitted events, so observing a decision meant standing up an engine, a database and a real race; each is now covered by generated inputs in under a tenth of a second. - **An injected clock for the workflow engine** (`clock.ts`). Every `Date.now()`, `Math.random()` and timer in `src/client/workflow/` now reads from it, and the real clock is the default, so nothing changes unless a test installs `simulatedClock(seed)`. With one installed, retry backoff, signal timeouts, execution ids and persisted timestamps all become functions of that seed, so a failure replays exactly instead of once in eleven campaigns. Measured on a live run with two retries: 1794 ms of wall time becomes 66 ms, with the waiting visible on the simulated clock instead. SQLite, the queue's worker loop and the OS scheduler are still real, so the engine as a whole is not deterministic; its own contribution is. - **A deterministic simulation suite** (`test/workflow-dst.test.ts`). - **Property-based tests over the workflow engine's pure core** (`test/workflow-properties.test.ts`): round-trip across the persistence boundary, idempotency-key stability and metamorphic behaviour, loop-name inversion, and the gate guard checked against a naive oracle. It found the inherited-member gate defect above on its 202nd generated case. - **Saga hardening.** The failing step and sub-workflow records are part of the unwind set; a failed reversal parks the run in the non-terminal `compensation-stuck` state, with `engine.resumeCompensation()` and `engine.abandonCompensation()` as the ways out; `.pivot()` marks a point of no return past which nothing is rolled back; and `rollbackStatus` is tracked separately from `failureReason`, because "the payment failed" and "the refund never went through" need different alerts. - **Idempotency keys** on every step, shaped `run:step#occurrence:direction` and invariant across retries and crash-resume. Compensate handlers also receive `ctx.forwardIdempotencyKey`, so a rollback can ask a provider whether the forward operation actually happened. - **Loop memoisation.** A completed iteration is not re-run when its node is re-entered after a crash, so a loop resumes at the iteration it was interrupted on. - **Versioned API reference** at `/reference//`, generated from source with TypeDoc over the package's own `exports` map. Build it with `bun run docs:api`. - **A dedicated Workflow Engine section in the guide**: nine pages covering steps and control flow, rollback, durability, human approval, and integrations with the Vercel AI SDK, Claude Agent SDK, OpenAI Agents SDK, Mastra and LangGraph. Every example on those pages is executed by a test. - Types reachable from `Execution` and `StepRecord` are now exported and nameable: `RollbackStatus`, `CompensationStatus`, `CompensationOutcome`, `BranchCondition` and `WorkflowNode`. ### Changed (experimental API) - **`signal()` on a run that is not running or waiting now throws.** It used to be accepted: the payload was written into the persisted row and `signal:received` was emitted, so a dashboard reported an approval against a run that had already ended and a closed audit record was mutated after the fact, while the caller got a clean return for a delivery that did nothing. A signal racing a run to its end is real, and this is how the caller finds out. - **`__proto__` is refused as an event name**, at `register()` and at `signal()`. Assignment to that name writes an object's prototype instead of creating a key, so the payload was stored nowhere: the gate never saw its own signal, re-parked, expired on its timeout, and the unwind reversed work the approver had authorised. Supporting it would mean reconciling the storage codec, which renames `__proto__` to `__proto_` as its own pollution defence, so the gate would be stored under a different name than it was signalled with. - **Two `waitFor` nodes on the same event are now rejected at `register()`.** A delivered signal is never consumed, and a wait is satisfied by the event key being present, so a run shaped `waitFor('approve')`, pay, `waitFor('approve')` was walked end to end by ONE signal: the second gate never paused. A four-eyes control silently became a one-eye control, with nothing in the state, events or logs to say a gate had been skipped. Give each gate its own event name. A `waitFor` with no event name at all is refused for the same reason: a gate nobody can name is a gate nobody can open, and two of them were opened by one signal. - **`engine.abandonCompensation()` is now `async`.** It did the same synchronous work but threw synchronously, so the defensive form an operator reaches for under pressure, `Promise.allSettled([resume(id), abandon(id)])`, threw before `allSettled` was ever called instead of settling. It now matches its sibling. - **`forEach` now rejects a non-array item source.** It read `.length` and indexed directly, and JavaScript is generous about what has a length: a number iterated ZERO times and still reported `completed`, so a batch that processed nothing looked exactly like a batch with nothing to do, and a string iterated its CHARACTERS, so an id list that arrived as `'u1,u2'` silently processed five items nobody passed. Only `null` and `undefined` failed, and only by accident. It now throws. - **Two workflow shapes that used to register now throw at `register()`.** Both were accepted before and neither did what it looked like: - a `waitFor` (or any non-step node) inside `.path()`, `.parallel()` or a loop body. A path runs inline inside a single job, so it has nowhere to park: on 2.8.46 such a run **completed without ever waiting for the signal**, silently skipping the approval gate. - a step whose name collides with a loop's `name:index` namespace, e.g. a step called `turn:0` beside a loop body called `turn`. Harmless before because loops did not write indexed records; this release introduces them, and the collision would overwrite an iteration's history. - **`ExecutionState` gained `compensation-stuck`.** An exhaustive `switch` over that type in consumer code will no longer compile until the new case is handled. - **`compensate: async (ctx) => ...` now type-checks.** The option was a union of two function types, and TypeScript cannot contextually type a parameter against a union of signatures, so the inline form used by every documented example was an implicit `any` and failed under `noImplicitAny`. It is now a method taking a permissively typed step map, which restores inference while still accepting every handler shape the union accepted. The trade is that `ctx.steps` inside a rollback is not narrowed to the accumulated step types; handlers that want that annotate their own parameter. ### Notes The workflow engine is a Bun, in-process API. It is not part of the wire protocol and is not implemented in the Python, PHP, Go, Rust or Elixir clients; those clients can push jobs that a Bun process running a workflow then consumes. **Upgrading with a run already parked.** Step records now carry `compensatable`, which is what tells "never owed a reversal" apart from "owed one and the step has since been renamed away". Rows written by an earlier version do not have the field, so a run that was already parked in `compensation-stuck` before the upgrade keeps the old behaviour for the renamed-step case: its unreached steps are treated as owing nothing. Runs started after the upgrade carry the field from their first write. If you have a parked run you care about, resolve it before upgrading. ## [2.8.46] - 2026-07-22 ### Fixed: hardware-independent banner regression coverage - Startup banner regression coverage now validates the runtime shard count against bunqueue's hardware-derived value instead of assuming the 16 shards used by the development machine. CI runners with fewer logical CPUs no longer reject an otherwise correct banner. ## [2.8.45] - 2026-07-22 ### Changed: clearer polyglot startup identity - The startup banner and CLI help now use `One queue. Any language.` instead of describing bunqueue as a job queue limited to Bun. The server and embedded runtime remain Bun-native, while network clients can use other runtimes and languages. - Startup state is easier to scan with aligned labels and distinct markers for enabled, disabled and informational rows. Storage now identifies ephemeral in-memory operation or the configured SQLite path, and Unix sockets and logical CPU counts use explicit terminology. - The documentation terminal mirrors the production banner, and regression coverage starts a real broker to protect the product line and status layout. ## [2.8.44] - 2026-07-20 ### Fixed: transaction-safe S3 recovery and production monitoring - Scheduled and manual S3 backups now snapshot SQLite through `VACUUM INTO`, so committed WAL frames are preserved even when a reader blocks checkpoint truncation. Server snapshots first flush pending buffered writes and reject the cycle if storage retry/backoff leaves any accepted write only in memory. - Backup keys include a UUID; metadata is published before its gzip payload. Restore strictly validates metadata, compressed/original sizes, SHA-256, SQLite header and `PRAGMA integrity_check`, then quarantines stale WAL/SHM/journal sidecars before the atomic swap. Legacy no-metadata restore is restricted to uncompressed SQLite files. - Temporary S3 session credentials and virtual-host addressing are supported. Paginated listing now surfaces errors instead of treating an outage as an empty bucket, and every S3 retry attempt has a released timeout. Enabling backup without a persistent data path now fails startup before binding instead of silently running without recovery points. - Prometheus metrics now use canonical registration gauges and `_duration_seconds` histograms, and expose worker capacity, process memory, storage health and SQLite size. Health/readiness return 503 for degraded storage, TCP connections are reported accurately, and protected metrics fail closed when no auth token is configured. - Enterprise telemetry adds standard `process_*` collectors, bounded build and transport labels, an explicit per-queue cardinality cap with exported/omitted conservation, and zero-initialized S3 backup scheduler/freshness/outcome metrics. The default cap is 100 queue names and is configurable with `METRICS_MAX_QUEUES` or `telemetry.maxPrometheusQueues`. - The pinned Compose monitoring profile now includes Alertmanager, a matching Grafana datasource UID, corrected alert expressions, and a dashboard with queue filtering, per-queue state, latency percentiles/heatmap, worker utilization, storage/server indicators, connections, backup freshness and telemetry-cap visibility. Bundled alerts cover stopped/stale/failing backups and omitted per-queue metrics. - New model-based campaigns verify backup publication/restore/retention invariants, worker aggregate/capacity conservation, backup outcome conservation and queue-label bounds alongside the existing broker lifecycle model. ## [2.8.43] - 2026-07-19 ### Fixed: collision-free SDK broker fixtures - TypeScript SDK E2E fixtures now let the operating system allocate each broker's unused HTTP port independently. Starting the dedicated auth broker can no longer collide with the primary fixture's TCP port and wait 15 seconds before failing. - Regression coverage keeps the general, crash/restart and Cloudflare Workers harnesses on the collision-free port strategy. ## [2.8.42] - 2026-07-19 ### Fixed: reliable scheduled SDK soak tests - Weekly SDK soaks now raise the disposable broker's protocol request budget, so long-lived single-connection profiles measure SDK health instead of stopping at the production anti-abuse limit after roughly one minute. - The Elixir soak now follows the SDK's public unit-operation contract: `Queue.obliterate/1` returns `:ok`. - Weekly dependency advisories run in a dedicated workflow, preserving the same schedule while keeping CI definitions within the 300-line limit. ## [2.8.41] - 2026-07-19 ### Performance: batch pulls scan ineligible jobs once - `pullBatch` now parks delayed and active-group-blocked candidates in one scratch area for the entire batch, then restores them once before releasing the shard lock. This removes the repeated extract/reinsert cycle for every delivered job while preserving priority, FIFO groups, limiter accounting, long-poll deadlines and queue indexes. - Native benchmarks with 50,000 ineligible jobs and a batch of 100 improved active-group backlogs from roughly 1.54 seconds to 18 milliseconds (about 85x) and delayed backlogs from roughly 1.45 seconds to 16 milliseconds (about 90x). - Regression coverage verifies single restoration, partial batches under concurrency limits, earliest delayed wake-up and exception-safe restoration. ## [2.8.40] - 2026-07-19 ### Fixed: deterministic and interruption-safe CLI - The CLI now derives command routing and command discovery from one canonical registry. Help, aliases and the command builder expose the same surface, including `ping` and `backup create`. - `--json` emits exactly one JSON document on local, remote and error paths. Safe-integer parsing rejects imprecise values, while negative JSON primitives, equivalent flag spellings and independent flag ordering retain their intended meaning. - Closing a TCP connection now cancels its pending pull. An interrupted long-poll cannot claim the next job, leave a hidden waiter or consume rate/concurrency resources while waiting for a shard lock. ### Fixed: lossless MessagePack keys and durable state transitions - TCP, SQLite, CLI and cloud transports share a canonical MessagePack codec. Hostile object keys such as `__proto__` round-trip without renaming, collisions, data loss or prototype pollution while ordinary frames retain the fast decoder path. - Active-to-waiting transitions, per-queue DLQ configuration, stall counters and related queue indexes now persist and recover coherently. The generated broker model covers 69 lifecycle, ordering, resource and persistence invariants. ### Added: exhaustive CLI and failure-path validation - Property campaigns cover arbitrary argv, safe-integer boundaries, Unicode, JSON values, serialization and flag permutations. Real TCP/SQLite E2E tests cover every CLI command, restart persistence, direct-API parity, malformed inputs, concurrent idempotency, duplicate ACKs and killed long-polls. - A targeted mutation campaign killed all five parser, router, codec, JSON and cancellation mutants. Workflow retry tests now observe terminal state instead of sleeping for eight seconds, cutting the reported retry case from about 8.1 seconds to about 2.1 seconds under the isolated parallel suite. - The real-TCP count regression harness now binds a kernel-assigned port, so parallel CI cannot collide with an unreserved random port. ## [2.8.39] - 2026-07-18 ### Fixed: overload correlation and complete stale-dependency cleanup - TCP rate limiting now charges each complete MessagePack frame instead of each socket data event. Partial frames consume no quota, coalesced frames are limited independently, and overload responses preserve the triggering `reqId` so multiplexed clients can settle the correct request. - Stale dependency cleanup now removes durable SQLite state, buffered writes, reverse dependency edges, queue ownership, custom and unique identifiers, the global job index, and in-memory waiting state as one revalidated transition. Expired dependency-gated jobs can no longer remain readable or retain identifiers after garbage collection. ### Fixed: flow results remain available to live dependants - A dependency-result tracker now retains a producer result while at least one live consumer edge needs it. Fan-in, fan-out, single and batch ACK, `removeOnComplete`, retries, terminal failures, cancellation, cleanup, recovery, drain, and obliteration all update the same edge lifecycle. - Dependency result lookup distinguishes a cached `null` result from a cache miss and falls back to durable storage consistently. Normal result-cache pressure can no longer make a declared flow lose child results before its parent is released. ### Added: production, destroy, and cross-queue invariant coverage - Real public-TCP production tests now exercise durable enqueueing, worker concurrency, retries, delayed jobs, priorities, flow dependencies, restart recovery, backpressure, health responsiveness, duplicate-effect detection, and complete drain under a large backlog. - The generated model now covers multi-queue shard isolation and global conservation. Retention-boundary, protocol-correlation, stale-dependency, and dependency-result regressions bring the executable production register to 56 invariants, with the remaining contract-dependent candidates recorded explicitly in the internal testing reference. ## [2.8.38] - 2026-07-18 ### Changed: adoption-first npm README The package README was rewritten from a 958-line reference dump into a ~370-line adoption path: quickstart (embedded and server in the first screen), why/when comparison, the two modes, the six-language SDK table, Simple Mode and Workflow teasers, and a short MCP setup, each section deep-linking to the corresponding bunqueue.dev guide. Duplicated sections, stale SDK lists and internal test details were removed; every surviving claim and code sample was re-verified against the current API. The README and the site now state explicitly that the server and the embedded queue run in-memory unless a data path is configured. ### Changed: every docs example in all supported languages Twenty guide pages now show each client example in synced language tabs covering Bun, Node.js/Deno, Python, PHP, Go, Rust and Elixir: the queue, worker, quickstart, cron, DLQ, rate-limiting, flows, Simple Mode, TLS, stall-detection, webhooks, troubleshooting, FAQ, security, migration, installation, introduction, databases, examples and use-cases pages. Every non-TypeScript sample was verified against the SDK sources; where a feature does not exist in a language the docs say so explicitly instead of showing code that would not compile. Bun-only pages (workflow engine, QueueGroup, IoT forward(), Elysia/Hono, sandboxed workers) carry an explicit runtime callout. ### Changed: bunqueue.dev home covers all six SDKs The home hero now shows the real `bunx bunqueue start` boot output in an animated, replayable terminal next to the per-language install step, with Rust and Elixir added everywhere (install chips, language cards, trust line). The quickstart and developer-experience code examples switched to language tabs covering Bun, Node.js/Deno, Python, PHP, Go, Rust and Elixir, the benchmark card moved next to the BullMQ comparison, and the layout uses the full desktop width. ## [2.8.37] - 2026-07-17 ### Fixed: Go SDK conformance in CI - The Go conformance job now enters the nested driver module explicitly with `go -C drivers/go run .`. Running `go run ./drivers/go` from `sdk/conformance` searched for a parent `go.mod` and failed before the driver could connect to the broker. - A regression test now verifies the workflow command itself. The isolated core-test image includes the SDK workflow file so the contract is exercised by `bun run test:sandbox` and cannot silently drift. - The late-dependency TCP regression now delegates port allocation to the kernel instead of guessing an unreserved high port, eliminating an `EADDRINUSE` race in the parallel unit gate. - The testing and conformance references now document the runner working directory, the native CI command, and the prebuilt driver used by `test:sandbox:sdk`. ## [2.8.36] - 2026-07-17 ### Added: real-broker model-based state machine - A `fast-check` asynchronous command model now generates lifecycle, batch, dependency, DLQ, limiter, queue-control, and actual `SIGKILL`/restart histories against a fresh TCP broker and SQLite database per run. After every command it verifies API state, aggregate counts, lock tokens, MessagePack payloads, priority, physical rows, DLQ membership, and persisted queue controls. Failures shrink and replay by seed via `bun run test:model`. - The first campaign found and permanently covers two crash-durability bugs: `Update` changed payload only in memory, and `ChangePriority` failed to persist priority/LIFO ordering. Both mutations now flush a pending buffered insert before updating SQLite and survive restart. - Repeated crash recovery now persists each job's cumulative `stallCount` and the queue's complete custom stall policy before classifying active work. Recovery, heartbeat stalls, and expired locks all enforce both `maxAttempts` and `maxStalls`; terminal recovery restores its DLQ row exactly once even across repeated restarts. - TTL expiry now deletes the persisted row and cancels any pending write-buffer insert in the same logical transition that removes the job from its heap, counters and indexes. Expired work cannot reappear through `GetState` or after restart. - Queue obliteration now removes dependency-gated jobs from `waitingDeps`, `waitingChildren`, and the reverse dependency index, then uses the complete removed-ID set to purge global indexes and SQLite. Public embedded and TCP job counts now consistently expose the `waiting-children` bucket, including zero after obliteration. - Manual and expiry-based DLQ purge now removes terminal jobs from `jobIndex`, results, logs, buffered writes, and SQLite in one queue-scoped transactional cleanup. Repeated expiry cleanup is idempotent and cannot delete a newer live generation or another queue's entry. - Reusing a terminal custom ID now retires its prior DLQ generation before admitting the replacement. PUSH and PUSHB acquire the target and prior-owner shards in deterministic order and revalidate after locking; live custom IDs remain globally idempotent and the broker never exposes two generations with the same `jobs.id`. - Management commands that claim active work (`MoveToDelayed`, `MoveToWait`, `MoveToWaitingChildren`, and active `Discard`) now release both the live lease and TCP-client ownership through one idempotent cleanup. No stale lock or client tracking remains after the job leaves `active`. - The full sandbox gate exposed a tenth persistence-boundary defect after adding durable stall counters: legacy/low-level jobs without `stallCount` violated the new SQLite `NOT NULL` column. Single, buffered, batch, retry, and decode paths now normalize an omitted value to zero while the schema remains strict. - The testing reference now tracks the complete 46-invariant production checklist across 15 categories and names whether each category is owned by the generated lifecycle model or a focused cron, flow, worker, protocol, or storage suite. ### Added: six-language production SDK gate - Rust and Elixir join TypeScript, Python, PHP, and Go as official protocol-v2 SDKs, each with Queue, Worker, FlowProducer, verified TLS, auth-first lazy reconnect, typed errors, JavaScript-safe MessagePack handling, structured telemetry, native regression coverage, and the shared 17-check conformance driver. - `bun run test:sandbox:sdk` builds six pinned toolchain images, validates package contents plus every native/conformance suite without runtime network access or host mounts, and writes complete logs, NDJSON resource samples, per-suite JSON, anomaly signals, and slow-test rankings. - The TypeScript, Python, PHP, and Go clients gained frame-boundary, serialization, timeout/reconnect, option-forwarding, worker-lease, TLS, and telemetry regressions. Invalid outgoing data now fails through each SDK's typed error hierarchy before it can retain an in-flight slot or write a frame. - Browser WebAssembly remains a documented future target because portable WASM has no raw-TCP API. A future browser bridge or capability-enabled WASI client must pass the same security, telemetry, and conformance contract before it is listed as official. - All six native suites now include independent-connection idempotency and single-lease races, generated payload invariants, malformed-input fuzz corpora, bounded spike tests, and durable SIGKILL/restart recovery. Go also runs the race detector and a native fuzz target. - Every SDK has an opt-in long-lived soak/stress profile. Weekly CI runs the profiles for 15 minutes, exercises the full runtime compatibility matrix, and checks package advisory databases; the authoritative local SDK gate remains deterministic, offline, and bounded. ## [2.8.35] - 2026-07-17 ### Added: awaitable client API variants - New `Async` variants on `Queue` that resolve only after the server has processed the command: `obliterateAsync()`, `pauseAsync()`, `resumeAsync()`, `drainAsync()` (returns the removed count), `retryDlqAsync(id?)` and `purgeDlqAsync()` (return the server count the fire-and-forget forms discard), `setStallConfigAsync()`, `setDlqConfigAsync()`, `setGlobalRateLimitAsync()`, `removeGlobalRateLimitAsync()`, `setGlobalConcurrencyAsync()`, `removeGlobalConcurrencyAsync()`. - `getDlqJobsAsync(count?)` lists a remote server's dead jobs over TCP via the existing `Dlq` command (plain Job objects; DLQ metadata such as the failure reason stays embedded-only). - Why: fire-and-forget commands travel over a 4-connection pool with no ordering guarantee, so `obliterate()` followed by `add()` could wipe the new job. The awaitable forms close that race; two TCP test suites were flaky for exactly this reason and now use them. ### Fixed: rate-limit `duration` honored end-to-end - `queue.setGlobalRateLimit(max, duration?)` now means "`max` jobs per `duration` ms" in both embedded and TCP modes (default stays 1 second). The `RateLimit` wire command and `PUT /queues/:queue/rate-limit` gain optional `duration`; invalid values degrade to the defaults instead of failing. - Previously the client accepted `duration` for BullMQ compatibility and silently dropped it: "100 per minute" behaved as "100 per second". ### Fixed: temporary rate limit expires broker-side - `queue.rateLimit(expireTimeMs)` now sets the limit with a broker-side `ttl`: the server clears it by itself, lazily, in embedded and TCP mode alike. Previously the expiry was a client-side timer in embedded mode and never happened over TCP (the "temporary" limit was permanent). Invalid input now throws. TTL'd limits persist across restarts with their remaining time and never resurrect once expired (schema migrations 15-16 add `queue_state.rate_limit_duration` / `rate_limit_expires_at`, one column per migration so interrupted upgrades heal on retry). ### Fixed: skipped cron fires no longer consume the `maxLimit` budget - The scheduler incremented and persisted `executions` before the skip checks (`skipIfNoWorker`, overlap guard), so a skipped fire burned one run of the cap without pushing a job — a `skipIfNoWorker` cron with no worker could exhaust its entire budget with zero deliveries. Skip decisions now run before the increment: a skip advances the schedule and emits `cron:skipped` but leaves the budget untouched, so `executions` counts actual deliveries. ### Fixed: two flaky TCP integration tests - `test-flow-advanced` and `test-frameparser-pipelining-e2e` raced their fire-and-forget `obliterate()` against the jobs they pushed right after (the CI failures on July 17); both now await `obliterateAsync()`. The 20k-row recovery regression test gained an explicit timeout for slow CI runners. ## [2.8.34] - 2026-07-17 ### Added: isolated parallel test gate with engineering telemetry - `bun run test:sandbox` builds the current worktree into one pinned Debian/Bun test image and runs the mandatory unit, TCP, and embedded suites concurrently in three disposable containers. Containers have no host mounts or external network, run non-root with capabilities dropped, and use independent filesystems, ports, processes, and SQLite databases. - Every run preserves complete suite logs, timestamped Docker resource samples in NDJSON, per-suite JSON, and an aggregate JSON/Markdown report. KPIs include duration and test/file counts, CPU average/p95/peak, memory start/p95/end/peak and slope, PID peak, block/network I/O, slow-test rankings, OOM detection, and baseline regression signals. - TCP functional files now each start a fresh server with dynamic TCP/HTTP ports and a unique temporary SQLite database. This removes cross-file state and port coupling while keeping the exact public persistence path under test. - `AGENTS.md`, `CLAUDE.md`, CI, and the internal architecture/testing reference now define the same mandatory three-suite gate, containment limits, diagnostic fallback, telemetry review, and native-only benchmark policy. ### Fixed: `promoteJobs()` live-state and persistence correctness - Bulk promotion no longer queries the eventually consistent SQLite listing before the write-behind buffer has flushed. Embedded and TCP modes now select delayed jobs from the live shard in stable `(createdAt, id)` order, apply an exact optional count, update heap/temporal tracking/counters under one shard lock, wake queue-local waiters once, and persist every promoted `run_at` before resolving. - The single-job promotion path now maintains the same delayed tracking, persistence, and waiter invariants. The regression keeps non-durable delayed seeds specifically to cover the former write-buffer race and verifies `count: 0`, live counts, and SQLite-backed listings. Validation on Bun 1.3.14: 5,845 unit tests passed (3 explicitly skipped), 402 TCP assertions passed across 60 fresh-server files, and 273 embedded assertions passed across 36 files. The parallel gate completed in 15.15 minutes with no failures or OOM events; its full telemetry is retained as a local build artifact. ## [2.8.33] - 2026-07-16 ### Fixed: recovery, job pagination, and FIFO-group scheduling correctness - SQLite startup recovery no longer skips the row that crosses a shrinking `OFFSET` page boundary. Active jobs are recovered in stable offset-zero batches, each transition updates counters exactly once, and corrupt pending rows are quarantined only after stable pagination completes. A 10,001-job regression now recovers all 10,001 jobs with no active row left behind. - `getJobs()` now filters, sorts, and paginates in the correct order in both memory and SQLite. Descending pages are selected from the globally ordered result; logical `waiting`, `prioritized`, and `delayed` predicates execute in SQL before `LIMIT`/`OFFSET`; equal timestamps use job ID as a deterministic tie-breaker. New queue/creation indexes remove SQLite's temporary ORDER BY B-tree on deep pages. - Pull no longer returns `null` merely because the heap head belongs to an already-active FIFO group. Blocked candidates are parked while the pull scans for an eligible group and are then restored without changing queue counters or indexes. Group and concurrency releases also wake the appropriate queue-local long poll. Existing delayed-head semantics remain unchanged. ### Improved: summaries, temporal indexes, waiters, and delayed-heap memory - Queue summaries use one aggregation pass and include `prioritized` jobs explicitly. HTTP/SSE/WebSocket queue-count refreshes are coalesced through a shared scheduler instead of rescanning global state once per queue. The 200-queue/50k-job benchmark improves from 503.6 ms to 4.9 ms median (approximately 103x). - Temporal cleanup now uses a queue-local ordered index plus direct job-ID lookup. In the 500k-unrelated-job benchmark, sparse lookup improves by about 14,935x and removal by about 3,108x. - Waiters now use queue-local cursor deques, cancel fulfilled timers immediately, compact stale entries proportionally, and coalesce surplus notifications instead of accumulating false pull credits. Notifying 10,000 waiters improves from 446 ms to 0.8 ms median (approximately 553x). - Lazy-deleted delayed-heap entries are rebuilt after a stale threshold and the heap is cleared immediately when no delayed job remains. A 100,000 add/remove churn now retains zero heap entries instead of 100,000. Every confirmed bug has a focused regression test. The release also adds a reproducible before/after benchmark harness and report, synchronizes the internal technical reference, and audits the Astro site and root README against the current protocol, HTTP events, state model, environment variables, indexes, and runtime behavior. ## [2.8.32] - 2026-07-11 ### Fixed: permanent concurrency-slot leak in moveJobToDelayed and discardJob When a queue had `setConcurrency(N)` and an ACTIVE job was claimed by `moveJobToDelayed` (the BullMQ-style "retry later" pattern inside a processor) or discarded to the DLQ, the claim path removed the job from processing without releasing its concurrency slot. Every sibling exit path (ack, fail, moveActiveToWait, lock expiry) releases; these two did not, and no background reconciliation exists, so N such claims wedged the queue permanently with no error anywhere, only `clearConcurrency` could unwedge it. Both paths now release inside the shard-lock section, mirroring the established semantics (uniqueKey freed on DLQ entry exactly like the fail path). Bonus from the same audit: discarding a WAITING job now releases its uniqueKey reservation (previously a re-add with the same key deduped against the DLQ'd job). Reproductions in `test/repro-slot-release-claim-paths.test.ts`, RED before, GREEN after, with control tests proving the sibling paths were already correct. ### Fixed: three "client or handler silently drops a supported option" siblings (#111 class) - `TcpConnectionPool` accepted `maxInFlight` and `pipelining` but never forwarded them to the clients it built, so every pooled connection ran with the default window of 100 no matter what you configured. Both are now forwarded and included in the pool sharing key, so queues with different windows no longer silently share a pool. The focused bench improved about 2 percent once the configured window was actually honored. - `PUSHB` skipped the validation single `PUSH` enforces: an out-of-bounds option or a `dependsOn` pointing at a nonexistent job was accepted in a batch (the dependent job then sat in waiting-children forever). Batch pushes now run the same data, option and dependency gates, extended so intra-batch chains keep working regardless of order in the array (the auto-batcher groups concurrent adds arbitrarily), with self-references rejected explicitly. Batch throughput cost is under half a percent. - Batch `PULL` without worker locks ignored its `timeout`, returning immediately instead of long-polling (workers using `useLocks: false` were busy-polling without knowing). The timeout is now validated and honored exactly like single `PULL`. Every fix started from a RED reproduction (`test/repro-option-drop-class.test.ts`); the audit also REFUTED two suspected bugs before any code was touched (rate-limit tokens self-heal by design, and `getJobByCustomId` is already covered by the 2.8.31 atomic transition, proven by running the same reproduction against the pre-fix commit). The refuted paths keep their tests as permanent regressions. ## [2.8.31] - 2026-07-10 ### Fixed: getJob/GetState returned a false null during the pull transition window Pulling a job popped it from the shard priority queue under the shard write lock while `jobIndex` still said `queue`; only after an await boundary did the index flip to `processing`. A concurrent `getJob`/`GetState` in that window followed the stale location, missed the already-popped queue, and answered `null`/`unknown` for a job that exists and is about to be active, violating the invariant that once `getJob(id)` answers null for a uuidv7 id it must stay null. Found while de-flaking an integration test whose poll loop hit the window reproducibly. Fix is structural, not reader-side: the pop, the `processingShards` insert and the index flip now happen in the same synchronous critical section, so no observer can ever see queue-but-popped, which repairs every reader at once (`getJob`, `GetState`, `GetJobs`, counts). The old post-await bookkeeping became `finalizeProcessing`, which also detects a management op (discard, moveToDelayed, obliterate) claiming the job between dequeue and finalize and skips delivery, keeping `PULLB` jobs/tokens aligned with no orphan locks. Queries additionally chase moved index entries with a bounded 4-pass walk instead of trusting one stale snapshot. Removing the async lock acquisition from the hot path improved pull+ack throughput about 9% in the focused bench. Regression test: `test/repro-getjob-false-null-during-pull.test.ts` (RED before, GREEN after). ### Fixed: three flaky tests that were failing CI on shared runners The soak marathon's latency-drift check now compares window medians instead of tripping on a single GC pause, the cron maxLimit test ticks until the cap instead of assuming a fixed tick count is enough (and handles the scheduler removing a limit-reached cron), and the embedded retry-backoff suite replaces blind sleeps with condition polling, which is what exposed the false-null bug above. ## [2.8.30] - 2026-07-10 ### Added: "24/7 readiness" battle-testing suites (adversarial, no source changes) Eight new adversarial test suites under `test/repro-*.test.ts` assert the delivery and resource guarantees a continuously-running deployment depends on. Each drives a real `QueueManager` + TCP server (several spawn the real `src/main.ts` process against on-disk SQLite) and asserts hard invariants, not just "it ran". Result: **50 tests / ~34.6k assertions, all green**, and no product bug surfaced (the guarantees already hold). - **Protocol fuzzing** (`repro-fuzz-protocol`), corrupt MessagePack, lying length prefixes, >64MB frames, torn/coalesced frames, and pre-auth commands never crash or wedge the server; the pre-auth gate never leaks state. - **Chaos / fault injection** (`repro-chaos-fault-injection`), at-least-once redelivery when a worker dies mid-job; a heartbeated-then-dropped job is not double-dispatched but is reclaimed by lock-expiry; lock-expiry under contention loses nothing; cron next-run is monotonic under clock skew/DST. - **Race / concurrency** (`repro-race-concurrency`), N concurrent PULLs → exactly one delivery; concurrent same-`jobId` PUSH → exactly one job; active re-add is an idempotent skip; cancel-during-active is a safe no-op; a stale ACK racing lock-expiry never double-completes; K-workers×M-jobs drain processes each exactly once. - **Crash-recovery** (`repro-chaos-crash-recovery`), under `SIGKILL`: durable jobs are never lost, ACKed durable jobs stay completed, paused-state and DLQ entries persist, an active-at-crash job is recovered, and multi-cycle crash fuzzing loses nothing cumulatively. - **Soak / endurance** (`repro-chaos-soak`), sustained produce/consume with a worker killed every ~400ms: no job lost (server-authoritative), p99 does not drift, WAL stays bounded, internal collections return to baseline after drain (no leak). Env-tunable (`SOAK_MS`) for multi-hour runs. - **Stress / degradation** (`repro-stress-degradation`), a huge backlog stays bounded and responsive then drains; 100 slowloris connections are all terminated by the stall bound while a healthy client stays fast; >50 pipelined commands on one socket all complete; latency returns to baseline after a spike. - **Upgrade / rolling restart** (`repro-upgrade-restart`), graceful `SIGTERM` flushes the write buffer so even buffered jobs survive; waiting/completed(+result)/paused/DLQ state all round-trip a restart; rolling restarts under load lose nothing. - **Long-running semantics** (`repro-longrunning-semantics`), cron next-run does not drift across thousands of ticks (incl. DST); `jobResults` and the custom-id dedup map stay bounded under pressure; the DLQ is bounded to `maxEntries` and remains retryable. Documented in `docs/architecture.md` (new _Reliability & Battle-Testing_ section). No runtime/API changes. ## [2.8.29] - 2026-07-09 ### Fixed: `upsertJobScheduler` silently dropped `limit` (#111, thanks @jdorner) `queue.upsertJobScheduler(id, { every, limit }, template)` accepted a `limit` in `RepeatOpts` but the client scheduler never mapped it to the cron engine's `maxLimit`, so the run cap was persisted as `NULL` and the scheduler fired forever. The whole backend already supported it (`CronJobInput.maxLimit`, `hasReachedLimit`, the `Cron` command, the handler), only the client builder omitted it. Fixed on **both** the embedded and TCP paths; `limit` is now surfaced back on `SchedulerInfo.limit` (via `getJobScheduler`/`getJobSchedulers`), the simple-mode `Bunqueue.cron()/every()` helpers gained a `limit` option, and the reporter's third request, exposing it on the return type, is honoured. RED→GREEN reproduction tests cover embedded and TCP. ### Fixed: audit of the same "client silently drops a supported field" class Auditing #111 surfaced three siblings of the same class, all fixed with reproduction tests: - **`retryJobs({ state:'failed', count })` ignored `count`**: the client sent it (or, in the SDKs, explicitly dropped it) but the `RetryDlq` command/handler had no `count` field, so the **entire** DLQ was retried instead of the requested N. Added `count` end-to-end (wire → handler → `retryDlq(queue, jobId?, limit?)` → a bounded `retryDlqJobs` that reuses the tested per-entry `retryDlqJob`, leaving the remainder in the DLQ). The Python and TypeScript SDKs now forward `count` too (forward-compatible: older servers ignore it). - **`Queue.moveJobToFailed()` dropped the stacktrace and `UnrecoverableError`**: the Queue reflection API and the job proxies sent only `{ error: message }`, losing the stack (#74 sibling) and treating an `UnrecoverableError` as a normal retryable failure. All four client failure sites (`jobMove`, two job proxies, the flow job proxy, the sandboxed worker) now route through a shared `failWire` helper that mirrors the worker path (`stack` + `unrecoverable`). - **`Worker.getNextJob()` ignored `lockDuration`**: the manual-acquire API used the server-default lock TTL on both the embedded and TCP paths, silently discarding a custom `lockDuration` (the main run-loop path already forwarded it). The polyglot SDKs were already correct on `limit`→`maxLimit`; only the count drop needed fixing there. ## [2.8.28] - 2026-07-09 ### Fixed: lock-expiry DLQ move was never persisted to SQLite (#110, root cause of #97's re-repros) The #97 fix (2.8.17) added `saveDlqEntry` + `deleteJob` persistence to `handleMaxStallsExceeded`, but the periodic lock sweep, the **only** production caller of `checkExpiredLocks`, builds its context with a file-local `getLockContext` that omitted `storage`. Both persistence calls silently no-op'd through optional chaining (`ctx.storage?.…` with `storage: undefined`), so a job failed via lock expiry (frozen worker + `maxStalls` reached) left an orphan `state="active"` row in the `jobs` table while its DLQ entry existed only in memory. A restart between the failure and a retry lost the real DLQ entry (failure reason, timestamps, attempt history); startup recovery could only fabricate a generic stalled entry from the orphan row. This is why #97 kept re-reproducing across 2.8.18 → 2.8.27 despite the mover itself being correct, and it also no-op'd the orphan-row cleanup for expired `preventOverlap` cron jobs on the same path. Fix: `getLockContext` now carries `storage: ctx.storage` (one line, exactly as diagnosed in the issue report, which traced it to the line and validated the patch RED→GREEN against the installed dist; thank you). The regression test goes through the real background-interval path and asserts **SQLite residency** (the `dlq` row exists, the `jobs` row is gone) rather than the in-memory view that had masked the bug in the existing lock-expiration tests. ### Fixed: embedded `retryDlqByFilter` never persisted (found by the #110 hardening) Making `storage` a **required (nullable)** field on `LockContext`/`DlqContext`/`QueueControlContext`, the reporter's third suggestion, so a forgotten dependency is a compile error instead of silent data loss, immediately surfaced a second instance of the same class: the embedded client's `getDlqContext` (`src/client/queue/helpers.ts`) also omitted `storage`. Embedded `queue.retryDlqByFilter(filter)` therefore re-queued jobs in memory only: the `dlq` row was never deleted (the job resurrected into the DLQ on restart) and the re-queued `jobs` row was never inserted (the retried job did not survive a restart). Both persistence calls now execute; regression test asserts SQLite residency through the public embedded API. ## [2.8.27] - 2026-07-08 A security + correctness release. Two TLS fixes reported against 2.8.20 & 2.8.26 (thanks @assantech), plus a client-SDK parity fix. Each ships with a RED→GREEN reproduction test. ### Security: TLS `data`-before-`open` could crash the whole server (#108) With native TLS enabled, Bun can deliver a socket `data` event before `open` has run (near-deterministic when a Worker boots its connections concurrently). The TCP `data` handler destructured `socket.data`, still null at that point, and the `TypeError` escalated to the process-level unhandledRejection handler, shutting the entire server down. Because the crash happens before authentication, any client that could reach an exposed TLS port could take the broker down (pre-auth remote DoS). Fix: per-socket state is now initialised lazily and idempotently from both `open` and `data` (`initConnection`), preserving that first frame; `close`/`drain` tolerate an uninitialised socket. Plaintext was never affected. ### Security: TLS client never verified the server certificate (#109) The TCP client's `tls` option was encryption-only: `Bun.connect` does not reject an unauthorized peer client-side, so every variant, including a **wrong pinned CA with `rejectUnauthorized: true`**, still connected. An active MITM could impersonate the broker and harvest the auth token. Fix: verification is enforced in a `handshake` handler using the `authorizationError` Bun computes. Verification is now the default for any TLS connection; only an explicit `rejectUnauthorized: false` opts out (encryption-only). A wrong/absent CA, a self-signed cert under system CAs, or `tls: true` against a self-signed server now reject with `TLS verification failed`. Two implementation details: the pinned CA is read into bytes (not a `Bun.file` handle) so Bun verifies against it, and every TLS connection resolves on `handshake` rather than `open` (registering a `handshake` handler makes Bun fire `open` before the handshake completes). ### Fixed: object-form `backoff` rejected over TCP `JobOptions.backoff` is typed `number | { type: 'fixed' | 'exponential'; delay }`, and embedded mode has always accepted both forms, but the TCP `PUSH` validator only allowed a plain number, so `queue.add(name, data, { backoff: { type: 'exponential', delay: 200 } })` failed over the wire with `backoff must be a number`. The server now validates and accepts the object form (`type` must be `fixed`/`exponential`, `delay` bounded like the numeric form), restoring embedded/TCP parity. Reported against the client SDKs; RED→GREEN reproduction test included. Also tightened the `PUSH` wire command type: `repeat` was under-declared (`every`/`limit`/`count` only) while the server consumes the full `JobInput['repeat']` shape (`pattern`, `tz`, `startDate`, `endDate`, …), typing-only, no runtime change. ## [2.8.26] - 2026-07-01 A correctness release from an exhaustive feature + extreme-stress audit (every subsystem, embedded **and** TCP). Seven fixes; all pre-existing, none data-loss in normal operation, each shipped with a RED→GREEN reproduction test. ### Fixed: idempotent re-add of an unfinished `jobId`/customId (active & waiting-children) Re-adding a job with an existing `jobId` while the prior job was **active** (being processed) or **waiting-children** threw `UNIQUE constraint failed: jobs.id` for durable jobs, or silently dropped the colliding insert (leaving an in-memory duplicate) for buffered jobs, instead of the documented idempotent no-op. `handleCustomId` only handled the still-queued case; it now idempotent-skips for every unfinished state, gated so a **completed** id still recycles into a fresh job (#92). The customId twin of the uniqueKey fix #69. ### Fixed: orphan `jobs` row no longer collides on the primary key (durable + buffered) A durable `jobs` row could outlive its in-memory tracking when `obliterate()` (fire-and-forget over TCP) or a write-buffer flush raced an in-flight insert, or when a completed customId job aged out of the 50k `completedJobs` window. Re-adding the same id then hit `UNIQUE constraint failed: jobs.id`. Both insert statements now use `INSERT … ON CONFLICT(id) DO UPDATE` (upsert): a brand-new id is a plain INSERT (zero hot-path cost), an orphan is overwritten in place. The `DO UPDATE SET` resets **all** non-id columns, including `started_at`/`completed_at`/`progress`/`progress_msg`/`last_heartbeat`/`stacktrace`, so a recycled id never inherits a prior life's `progress=100` or stale stacktrace. In the buffered batch path this also stops one stale collision from failing the whole flush and dropping every innocent job batched in the same window. ### Fixed: Workflow `engine.signal()` double-executed steps after `waitFor` Two concurrent/duplicate signals (or a signal arriving before the run parked) re-enqueued the current node, so every step after the `waitFor` (e.g. a side-effecting `charge`) ran twice, an exactly-once violation. `signal()` now records the payload always but only resumes a genuinely-parked run (`state === 'waiting'`), flipping to `running` synchronously so duplicate signals collapse to a single resume. ### Fixed: `moveToDelayed` was a silent no-op over TCP, and was not durable `Queue.moveJobToDelayed(id, timestamp)` / `job.moveToDelayed(timestamp)` over TCP left a waiting job waiting (no-op) and dropped the delay on an active job (re-queued as `waiting`). The client sent `{ timestamp }` but the command/handler read `delay` (→ `runAt = now + undefined = NaN`), and the server op only handled active jobs. The client now sends the relative `delay`, and `moveToDelayed` routes through `changeDelay` (handles in-queue + active). The new `run_at` is now **persisted** (`storage.updateRunAt`), so the delay survives a restart, previously `moveToDelayed`/`changeDelay` mutated only the in-memory heap and the delay was lost on recovery. Embedded was unaffected by the no-op bug. ### Fixed: `deduplication.replace` / `extend` ignored in embedded mode With the documented API `add(name, data, { deduplication: { id, replace: true } })` (no explicit `jobId`), embedded set `customId = deduplication.id`, so `handleCustomId` short-circuited the re-add before the replace/extend strategy ran, the original job survived. The dedup id now rides only on `uniqueKey` (matching TCP); `customId` is set from an explicit `jobId` only. `deduplicationId` on the returned job is sourced from `customId ?? uniqueKey` so it still reflects the requested id (#90). ### Fixed: `queue.getMetrics()` over TCP always returned `0` The TCP client read `response.stats.completed` / `.dlq`, but the `Metrics` handler returns `response.metrics.totalCompleted` / `.totalFailed`. The client now reads the correct fields. ### Security: webhook SSRF guard now blocks IPv4-mapped/-compatible IPv6 and IPv6 private ranges `http://[::ffff:127.0.0.1]/…`, the deprecated IPv4-compatible `[::127.0.0.1]`, and IPv6 ULA (`fc00::/7`) / link-local (`fe80::/10`) / unspecified (`::`) hosts bypassed the webhook SSRF check (in both dotted and WHATWG hex-normalized forms). The validator now unwraps mapped/compatible addresses and blocks the IPv6 private ranges before delivery. ## [2.8.25] - 2026-06-29 ### Fixed: `finishedOn`/`processedOn` always `undefined` on jobs from list queries (#104) `queue.getJobs()`, `getJobsAsync()`, and the `getCompleted()`/`getFailed()`/`getWaiting()`/`getDelayed()`/`getActive()` wrappers that delegate to them returned public job objects whose `finishedOn` and `processedOn` were always `undefined`, even for completed jobs, while the **same** job fetched via `getJob(id)` returned them correctly. Root cause: the list paths build jobs via `createSimpleJob`, which hardcodes `finishedOn: undefined`/`processedOn: undefined`, and never patched them from the internal job's `completedAt`/`startedAt` (unlike `progress`/`priority`/`attemptsMade`). `getJob(id)` worked only because it routes through `toPublicJob` → `buildJobProperties`. - Fixed in `src/client/queue/operations/query.ts` for **all three** affected sites: `getJobs` (embedded), `getJobsAsync` (TCP), **and** `getJob(id)` over TCP, the last was the inverse of the same gap (its TCP branch patched only `progress`, so post-fix it would have disagreed with `getJobs`). All now mirror `buildJobProperties`: a numeric timestamp maps through, `null` → `undefined` (guarded by `typeof === 'number'`). - The failure path is intentionally untouched: a failed job has no `completedAt` (only the success path sets it; `completedAt` doubles as a "completed" signal in cloud state classification and `waitUntilFinished`), so `finishedOn` stays `undefined` for failed jobs in **both** `getJob` and `getJobs`, consistent. A failed job's `processedOn` **is** populated (it was started), matching `getJob`. - Tests: `test/repro-issue104-getjobs-finishedon.test.ts` (6 embedded cases incl. parity with `getJob`, a populated failed-job `processedOn`, and a negative still-waiting case) and a new TCP integration case in `scripts/tcp/test-query-operations.ts` ("finishedOn/processedOn over TCP") exercising the real wire path for both `getJobsAsync` and `getJob(id)`. ## [2.8.24] - 2026-06-27 ### Performance: TCP frame parser made linear (O(F²) → O(F)) under pipelining `FrameParser.addData` (`src/infrastructure/server/protocol.ts`), used by **both** the TCP server (incoming commands) and the TCP client (incoming responses), resliced the entire remaining buffer after **every** decoded frame: `this.buffer = this.buffer.slice(4 + len)`. When many frames arrive coalesced in a single TCP read, exactly what deep pipelining and OS segment coalescing produce for `PUSHB`/`ACKB` bursts, that is O(tail) per frame, i.e. **O(F²)** in the number of frames per read. Replaced with a read-offset cursor that advances in O(1) per frame and compacts the unconsumed tail once, making the pass **O(total bytes)**. - Deterministic micro-benchmark (`addData`, 111-byte frames coalesced into one read): F=1000 **2.78ms → 0.043ms (~65×)**, F=5000 **61.1ms → 0.21ms (~291×)**. Linear scaling restored (5× frames → ~5× time). - End-to-end (M1 Max, Bun 1.3.14): TCP push throughput **+20–36%** at 1K–5K-job scales where frame coalescing is heaviest, neutral at larger scales; `tcp-bench` round-trip latency **p50 48µs → 43µs (−10%)**. Embedded mode is unaffected (it does not use the wire framing). - Behavior is byte-for-byte identical: frame bodies are still returned as copies, partial-frame buffering, the 64MB `FrameSizeError` guard, and the slowloris `hasPartialFrame`/`bufferedBytes` getters are preserved. New E2E suite `scripts/tcp/test-frameparser-pipelining-e2e.ts` validates 2000-way pipelined coalescing, 256KB multi-segment frames, 5000-job exactly-once processing, and boundary-size payload integrity. ### Performance: fewer copies and an O(Q²) background scan removed - **Dropped a redundant `new Uint8Array(data)` copy** in both TCP data handlers (`src/infrastructure/server/tcp.ts`, `src/client/tcp/connection.ts`). `addData` already copies the incoming bytes into its own buffer synchronously and never retains the caller's buffer, so the defensive wrapper was one full copy per read with no purpose. - **`cleanEmptyQueues` O(Q²) → O(Q)** (`src/application/cleanupTasks.ts`). The 10s background sweep called the `shard.dlq` getter, which rebuilds a `Map` of **every** queue's DLQ entries on each access, once per queue, making the per-shard sweep quadratic in the queue count. Replaced with the O(1) `shard.getDlqCount(queue)` counter lookup. Read-side only; no behavior change. ### Docs: benchmarks page re-measured and corrected Re-ran every published benchmark on an Apple M1 Max (Bun 1.3.14), reporting the **median of 3 runs** per cell, and updated `guide/benchmarks`. Embedded numbers reproduce (and are higher than before: bulk push peaks ~630K ops/sec). The **TCP “Process” figures were corrected**: the old 20–34K ops/sec single-worker numbers predate the lease-bounding over-pull fix and no longer reproduce, a single worker at `concurrency:10` is bounded by per-job pull round-trip latency (~182 ops/sec), scaling to ~4,900 ops/sec at `concurrency:50`. The methodology section now also documents that the TCP “Push” column issues 100 concurrent adds per batch (not sequential) and notes the `BUNQUEUE_EMBEDDED` env caveat when running `bench/comprehensive.ts`. ## [2.8.23] - 2026-06-24 ### Fixed: FlowProducer audit: two real defects (cross-queue parent linkage + `addBulkThen` result access) An adversarial audit of every `FlowProducer` feature (new suite `test/flow-producer-audit.test.ts`, 15 tests) confirmed 13 behaviors correct and surfaced two genuine bugs, both fixed RED→GREEN: - **`updateJobParent` corrupted a child's `__parentQueue`** (`src/application/queueManager.ts`). It set `data.__parentQueue = childJob.queue`, the **child's own** queue, instead of the parent's. For a **cross-queue** flow (`add({ queueName: 'P', children: [{ queueName: 'C' }] })`) the child's `Queue.getJob(...).parent.queueQualifiedName`, `.parentKey`, `.opts.parent.queue`, and `toJSON()/asJSON().parentKey` all reported `C` instead of `P`, breaking child→parent navigation. (Execution, `getChildrenValues`, `getFlow`, and `failParentOnFailure` were unaffected, they key off the domain `parentId`, which was already correct, so same-queue flows masked the bug.) Now set from `parentJob.queue`. `Queue.add({ parent })` already did this correctly (`add.ts`); `updateJobParent` was the lone divergence. - **`addBulkThen` produced a merge job that could not read its predecessors** (`src/client/flow.ts`). The `final` job was pushed via `pushJob` with `dependsOn = parallelIds` but **no `childrenIds`**, so `getChildrenValues(finalId)` returned `{}`, incompatible with BullMQ fan-in and with `add()`. It now pushes the final job via `pushJobWithParent` (children = the parallel ids): identical `dependsOn` ordering (still waits for all parallel jobs), but the merge step can now read their results via `getChildrenValues()` / `getDependencies()`. Note the linkage side effect: the parallel jobs now have the final job as their `parentId`, so a parallel step carrying `failParentOnFailure: true` will now fail the merge job (previously a silent no-op, since the parallel jobs had no parent). Two further audit candidates were investigated and **deliberately not changed** because they are not bugs: `getParentResult` returning `undefined` when a predecessor used `removeOnComplete: true` is an intentional memory-bounding trade-off (dependents still unblock via `depCompletions`), and the embedded-vs-TCP `customId`/`deduplication.id` fallback difference is not FlowProducer-specific, it mirrors the direct `Queue.add` paths. Regression-checked across 101 existing flow tests + all three suites (unit 5680, TCP 59/59, embedded 36/36). ## [2.8.22] - 2026-06-23 ### Fixed: `cancel()` / `removeAsync()` did not remove flow-chain jobs parked in `waitingDeps` ([#102](https://github.com/egeominotti/bunqueue/issues/102)) A dependent job created by `FlowProducer.addChain()` (e.g. `B` and `C` in a chain `A → B → C`) is parked in `shard.waitingDeps` (state `waiting-children`) until its predecessors complete. Its `jobIndex` location is `{ type: 'queue' }`, but `cancelJob()` only inspected the run queue and the `waitingChildren` map, it never checked `waitingDeps`. So `Queue.removeAsync(id)` (and `job.remove()`) on such a job returned `false`, **never called `storage.deleteJob()`**, and left the row in SQLite: the job reappeared after a server restart, leaking the dependency-index entry and its `uniqueKey` reservation too. `cancelJob()` now handles the `waitingDeps` case: it deletes the job from `waitingDeps`, unregisters its dependency-index entries, releases any held `uniqueKey`, drops it from `jobIndex`, and calls `storage.deleteJob()` (which also evicts a still-buffered job from the write buffer). It does **not** touch the queued counter, `waitingDeps` jobs are never counted there. RED→GREEN reproduction in `test/issue-102-cancel-waitingdeps.test.ts`: a `QueueManager` + real SQLite restart proving the row is gone and does not reappear, a `uniqueKey`-reuse-after-cancel case, and a faithful `FlowProducer.addChain` + `removeAsync` embedded repro. ## [2.8.21] - 2026-06-23 ### Performance: eliminated two O(n²) hot paths in batch push (`addBulk` up to 32× faster over TCP) Bulk job insertion (`addBulk` / `PUSHB`) degraded super-linearly with batch size, a single 5,000-job batch dropped to ~5k ops/s while embedded mode stayed flat. Profiling root-caused it to **two independent O(n²) hot paths**, both fixed: - **Temporal index comparator was not a total order** (`src/domain/queue/temporalManager.ts`). The cleanup index is a `SkipList` ordered by `createdAt` with jobId-based deduplication. In a bulk push `now` is captured once, so every job in the batch shares the same `createdAt`, making every node compare-equal, which (a) turned `SkipList.insert`'s duplicate-check scan into O(n) per insert ⇒ **O(n²) per batch**, and (b) made `SkipList.delete` remove the **WRONG** same-`createdAt` node (it stopped at the first compare-equal node, a latent correctness bug in `removeFromIndex`). Fixed with a total-order comparator `(createdAt, then jobId)`; jobId is a UUIDv7 string, so lexicographic order is a valid total order. Both the insert dedup scan and delete now resolve to the exact `(createdAt, jobId)` node in O(log n). Repro: `test/repro-temporal-onsquared.test.ts` (30k same-`createdAt` inserts: **10,975ms → 27ms**, plus a wrong-delete correctness case). - **SSE broadcast did per-event work even with zero clients connected** (`src/infrastructure/server/sseHandler.ts`). The TCP server subscribes both `wsHandler` and `sseHandler` to every job event. `wsHandler.broadcast` early-returns when no clients are connected, but `sseHandler.broadcast` did not, so every `pushed` event still paid `JSON.stringify` + `TextEncoder.encode` + ring-buffer churn and, worst of all, `getQueueJobCounts(queue)`, which is O(queue size + jobIndex size). During a bulk push the broadcast fires once per job after all jobs are already queued ⇒ **O(n²)**, even with no dashboard attached (the common high-throughput case). Fixed by mirroring `wsHandler`: `if (this.clients.size === 0) return;`. Behavior is unchanged whenever ≥1 client is connected. Repro: `test/repro-sse-broadcast-noclients.test.ts`. **Benchmark**, TCP `addBulk`, server in a separate process, same machine, clean DB per run; before/after measured by stashing the two fixes (apples-to-apples). Reusable harness added as `bench/tcp-bench.ts`: | batch size | before | after | speedup | | ---------- | ------------ | ------------- | ------- | | 100 | 28,196 ops/s | 63,403 ops/s | 2.2× | | 1,000 | 18,372 ops/s | 126,410 ops/s | 6.9× | | 5,000 | 5,276 ops/s | 170,013 ops/s | **32×** | Per-job cost went from super-linear (35 → 54 → 190 µs/job) to flat (~6 µs/job), matching embedded-mode throughput. All three suites green (5,663 unit + 59 TCP suites + 36 embedded suites). ## [2.8.20] - 2026-06-17 ### Fixed: embedded `job.remove()` / `removeAsync()` did not await the cancellation (RED→GREEN reproduction) - **`Queue.removeAsync()` (which backs the BullMQ-style `job.remove()`) returned before the job was actually removed, on the embedded path** (`src/client/queue/operations/management.ts`): the embedded branch fired `getSharedManager().cancel(id)` as a floating promise and `return`ed immediately, while the TCP branch correctly `await`ed its `Cancel` send. Because `cancel()` performs the removal inside an async write-lock (`cancelJob` → `await withWriteLock(...)`), `await job.remove()` could resolve before the job was gone (and any cancel error was swallowed as an unhandled rejection), inconsistent with the TCP path and a hazard under lock contention. The embedded path now `await`s `cancel()`. Surfaced by the new Biome `noFloatingPromises` lint (the old code was hidden behind a file-level `eslint-disable no-floating-promises`). Deterministic repro via lock contention in `test/repro-removeasync-floating-cancel.test.ts`. The synchronous `remove()` remains intentionally fire-and-forget. ## [2.8.19] - 2026-06-17 ### Fixed: a successful completion was lost when the lock expired mid-processing (#101; RED→GREEN reproduction) - **A job that was processed successfully could be recorded as `failed` when its lock token expired while the handler was running** (`src/application/queueManager.ts`): when `lockDuration` elapsed without renewal (e.g. a half-open TCP storm forcing a worker rebuild on a fresh connection), the handler still finished, but the completion ACK carried the now-expired token. The server rejected it (`Invalid or expired lock token`), the client `AckBatcher` burned its transient retries against this _permanent_ error and dropped the completion, and the job re-pulled → stalled → landed in `failed` despite having been processed correctly every time (observed ~350 jobs and 695× `acks lost` on one production queue). The ACK paths (`ack`, `ackBatch`, `ackBatchWithResults`) now apply a **grace window**: a completion is accepted when the job is still in `processing`, the lock entry's token still matches the presenting worker, and the lock belongs to the _current_ processing instance (`lock.createdAt >= job.startedAt`). The third condition is a **re-lease guard**: the stall path requeues a job without deleting its lock (the lingering lock is load-bearing, the Worker dedups re-pulls via `activeJobIds`, and the lock preserves the original owner's recovery path), so if another worker re-pulls the job its `startedAt` is reset to a newer time than the lingering lock's `createdAt`, the guard denies the grace, and the timed-out worker's late ACK is rejected, preventing a double-completion. In the genuine case (same worker finishing just after its own lock expired, no re-pull) the completion is recorded instead of being lost to a stall. At-least-once delivery already protected the data; this fixes the queue's accounting (success recorded as success). ### Fixed: queue control-state (paused / rate-limit / concurrency) was never persisted (#100; RED→GREEN reproduction) - **A deliberately paused queue silently resumed itself after a server restart, and rate-limit / concurrency overrides reset to defaults** (`src/application/queueManager.ts`, `src/application/backgroundTasks.ts`, `src/infrastructure/persistence/`): the `paused` / `rateLimit` / `concurrencyLimit` state lived only in `LimiterManager`'s in-memory Map. The schema declared a `queue_state` table for exactly this, but nothing read or wrote it, so any restart reset operator intent with no error or warning (a correctness/safety bug: a queue paused for maintenance, or to stop a misbehaving consumer, quietly resumed and processed jobs). The already-declared table is now wired: `pause`/`resume`/`setRateLimit`/`clearRateLimit`/`setConcurrency`/`clearConcurrency` **write through** to `queue_state` (UPSERT; an all-default state deletes the row instead of persisting a placeholder), `obliterate` drops the row, and `recover()` **loads** `queue_state` on boot and applies it to the owning shard. Control-state now survives restarts/upgrades/crashes. ## [2.8.18] - 2026-06-16 ### Fixed: Worker over-pulled (leased) jobs past `concurrency` (#98; RED→GREEN reproduction) - **A Worker leased more jobs than `concurrency`, inflating the broker's `active` count and starving other workers** (`src/client/worker/worker.ts`): the #96 fix capped _execution_ (`activeJobs`) at `concurrency`, but `doPullBatch()` computed free slots as `concurrency - activeJobs` and then awaited the pull with no reservation. Two leaks compounded: (1) several concurrent `finally → poll → tryProcess` runs each read the same stale `activeJobs` and each pulled a full batch; (2) a job just pulled by one run sat in the local `pendingJobs` buffer, leased and kept alive by the heartbeat (which renews locks for _all_ `pulledJobIds`, not just running ones), but not yet in `activeJobs`, so an overlapping pull never saw it. With `concurrency: 3` the worker held 5-6 jobs leased (3 running + buffered). `doPullBatch()` now caps the **leased** count (running + buffered + in-flight pulls): a new `pendingPull` counter reserves slots before the await (released in `finally`), and free slots are computed from `pulledJobIds.size` (the true leased set) instead of `activeJobs`. Group pull-ahead is preserved: when a group limiter is set and the buffer holds only group-blocked jobs, the worker still pulls ahead to find runnable jobs from other groups (verified by a liveness regression guard, no deadlock/starvation). Execution concurrency was already correct (no data loss); this fixes lease hoarding, the inflated `active` count, and head-of-line fairness across workers. ## [2.8.17] - 2026-06-16 ### Fixed: retry of a lock-expiry failure threw `UNIQUE constraint failed: jobs.id` (#97; RED→GREEN reproduction) - **Retrying a job that reached `failed` through the lock-expiry path failed with `UNIQUE constraint failed: jobs.id`** (`src/application/lockManager.ts`): `handleMaxStallsExceeded` moved the job to the DLQ using only in-memory state (`shard.addToDlq` + `jobIndex.set`). Unlike its three sibling paths, `ack.moveFailedJobToDlq` (max attempts), `stallDetection.moveStalliedJobToDlq` (heartbeat stall), and the startup recovery in `backgroundTasks`, it never called `storage.saveDlqEntry(entry)` nor `storage.deleteJob(jobId)`. So the `jobs` row survived in SQLite as an orphan (state `active`) and the DLQ entry lived only in memory. On retry, `dlqManager.retryDlqJob` re-INSERTs the job with its original id via the plain `INSERT INTO jobs` statement (not `INSERT OR REPLACE` like `insertResult`/`insertCron`), and the surviving orphan row raised the UNIQUE violation, failing the retry; a restart in that window also re-recovered the stale `active` row. The lock-expiry DLQ move now persists like its siblings (capture the `DlqEntry`, then `saveDlqEntry` + `deleteJob`), restoring the single-table-residency invariant. `deleteJob` also evicts the id from the write buffer, so a non-durable job cannot later flush a stale INSERT and re-orphan. ## [2.8.16] - 2026-06-16 ### Fixed: stale-ACK timeout resurrection (defect 3 from the destruction-validation audit; RED→GREEN reproduction) - **A late ACK from a timed-out worker could phantom-complete a retrying job, silently skipping the retry** (`src/application/queueManager.ts`, `src/application/backgroundTasks.ts`): for a job with a per-job `timeout` and `attempts > 1`, the timeout sweep requeued it for retry, but the still-hung worker's late ACK hit the stall-retry recovery path (Issue #33) and completed it anyway, overriding the timeout and skipping the retry. `isStallRetried()` could not distinguish a timeout-requeue from a stall-retry (both are `attempts > 0` in queue). The timeout sweep now records the job in a bounded `timedOutJobs` set; the ACK recovery paths (`ack`, `ackBatch`, `ackBatchWithResults`) discard a stale ACK for such a job (graceful no-op) so the retry proceeds. A legitimate ACK of the retry attempt carries a valid current lock token and bypasses the stale-token recovery path, so it still completes normally. The marker is cleared when a custom id is recycled, so idempotency-key reuse cannot inherit a stale marker. ## [2.8.15] - 2026-06-16 ### Fixed: 2 pre-existing defects surfaced by the post-2.8.14 destruction-validation test (each with a RED→GREEN reproduction) - **`Queue.getJobCounts()` silently returned all zeros in TCP mode** (`src/client/queue/operations/counts.ts`): the sync `getJobCounts()` hardcoded `{waiting:0,…}` for the non-embedded branch, so a TCP client got zeros while the server held the real counts. It now delegates to the async path for TCP (returns an awaitable `Promise` with the real counts); embedded mode stays synchronous. (`getDelayedCount()` was already async/correct.) - **PUSH of a late dependent on an evicted `removeOnComplete` parent was wrongly rejected** (`src/infrastructure/server/handlers/core.ts`): the TCP push dependency-existence gate checked `jobIndex`/`completedJobs` but not `depCompletions`, so a child depending on a completed `removeOnComplete` parent was rejected with "Dependency job not found" even though the readiness path and dependency processor already honored it. The gate now also consults `depCompletions` (new `QueueManager.getDepCompletions()` accessor). ## [2.8.14] - 2026-06-15 ### Fixed: 8 stability bugs from an end-to-end audit + destruction test (each with a RED→GREEN reproduction test) The data plane was already bulletproof under the destruction test (exactly-once held through a SIGKILL flood, zero corruption, lossless crash recovery, bad-input isolation). These fixes close feature-conditional defects in the control plane and resource hygiene. No change to data correctness or process stability for the default producer/consumer path. - **Concurrency slot leak on lock expiry** (`lockManager.ts`): `requeueExpiredJob` / `handleMaxStallsExceeded` now call `shard.releaseJobResources()` before re-queue/DLQ, mirroring the stall-detection paths. Previously a queue with `setConcurrency(N)` permanently wedged (throughput → 0) after N lock expiries under worker churn. - **Dependency children orphaned** (`ack.ts`, `ackHelpers.ts`, `dependencyProcessor.ts`, `push.ts`, `backgroundTasks.ts`, `sqlite.ts`): a child `dependsOn` a parent that returned `undefined` (across a restart) or had `removeOnComplete: true` was silently never run and dropped after 1h. Added a bounded `depCompletions` set for removeOnComplete parents and made dependency recovery recognize `state='completed'` rows (not only `job_results`). Fixes late-dependent ordering too. - **`addBulk` / PUSHB ignored `durable`** (`push.ts`, `sqlite.ts`): durable batch jobs sat in the 10ms write buffer instead of being written immediately like a single durable push. `insertJobsBatch(jobs, durable)` now writes the durable subset to disk atomically (single transaction), bypassing the buffer. - **Pool socket drop re-dispatched in-flight jobs** (`clientTracking.ts`, `worker.ts`): with `poolSize > 1`, dropping the connection that pulled a job re-queued a job a live worker was still running (double execution). `releaseClientJobs` now skips jobs whose lock was renewed (`renewalCount > 0`); the worker renews just-pulled locks immediately so the window cannot open. - **`Worker.close()` hang on buffered jobs** (`worker.ts`): a graceful close with group-limited buffered jobs hung forever; `close(true)` could not pre-empt it. Buffered (pulled-but-unstarted) jobs are now requeued on close, the drain waits only on genuinely in-flight jobs, and a force close pre-empts an in-progress graceful close. - **Worker not re-registered after a TCP reconnect** (`tcpPool.ts`, `worker.ts`): after a transient drop the worker vanished from `ListWorkers` / `getForQueue` while still consuming jobs. The pool now exposes `onReconnect()` and the worker re-registers on reconnect. (Visibility only, no data loss.) - **`moveToWaitingChildren` stranded the job** (`queryOperations.ts`, `jobManagement.ts`): a job moved to waiting-children was invisible to `getJob` and uncancellable. `getJob` / `getJobByCustomId` / `cancelJob` now consult `waitingChildren`. - **`perQueueMetrics` unbounded growth** (`queueManager.ts`, `cleanupTasks.ts`): the per-queue metrics map grew one permanent entry per distinct queue name and was not freed by `obliterate()`. It is now LRU-bounded and freed by `obliterate()`; cumulative counters survive a transient drain. ## [2.8.13] - 2026-06-15 ### Fixed: explicit `job.moveToFailed(err)` now carries the stacktrace (#74 follow-up) The 2.8.11 fix wired the failure stack through the **natural-throw** path only: a processor that `throw`s gets its stack sent on `FAIL` (persisted server-side) and set on the local `failed` event's `job.stacktrace`. A processor that catches the error and reports it explicitly with `await job.moveToFailed(err)` went through a different code path that never touched the stack, so, as @arthurvanl's repro showed, `job.stacktrace` was `null` on the `failed` event and `queue.getJob(id).stacktrace` stayed `null`, while an equivalent natural throw populated both. - **`moveToFailed()` sends the stack.** The explicit handler now computes the stack lines and includes them on `FAIL` (`stack`, TCP) / `manager.fail(..., wireStack)` (embedded), so the server persists them exactly like the throw path, visible via `getJob()` and in DLQ entries. - **Local `failed` event populated.** The manual-move handler now sets `job.stacktrace` (capped at `job.stackTraceLimit`) on the emitted job, matching the natural-throw behavior. - The stack-splitting logic is now a single shared `computeStackLines()` helper used by both paths, so they can't drift apart again. Reproduced in both modes with `test/repro-issue74-movetofailed-stacktrace.test.ts` (local event + server-side `getJob()` persistence, embedded and TCP). ## [2.8.12] - 2026-06-15 ### Fixed: Worker no longer overshoots `concurrency` under bursty completions (#96) The concurrency gate lived only in `poll()` (`activeJobs >= concurrency`), but the counter is incremented later in `startJob()`, with `await doPullBatch()` (a TCP round-trip) in between. Nothing serialized concurrent `tryProcess()` runs, so a burst of fast-completing jobs (e.g. a DLQ retry that finds nothing to do) could fire several `finally → poll → tryProcess` calls that all passed the gate while `activeJobs` was still low, all suspended at the pull await, and each then called `startJob()`, driving `activeJobs` past the configured limit. A second path made it worse: `startJob()` schedules `tryProcess()` via `setImmediate`, which bypasses `poll()`'s gate entirely. Reported over TCP with a slow network: up to 10 jobs in flight against a `concurrency` of 3. - **Re-check the gate before starting.** `tryProcess()` now re-tests `activeJobs >= concurrency` immediately before `startJob()`. There is no `await` between the check and `startJob()`'s `activeJobs++`, so the check is atomic with the increment and cannot overshoot. This single guard closes both the pull-await path and the `setImmediate` bypass. - **No job loss.** When the gate is closed the already-pulled job is requeued to the front of the worker's local buffer (it stays owned via the pull lock) and is started as soon as a slot frees. Reproduced with a deterministic test that models the slow pull (`test/issue96-concurrency-race.test.ts`): observed concurrency now stays at the limit (was 4 with `concurrency: 3`). ## [2.8.11] - 2026-06-12 ### Fixed: job stacktrace persisted server-side (#74 follow-up) The 2.6.110 fix populated `job.stacktrace` only on the worker's in-process `failed` event object. The stack never reached the server: `FAIL` carried just the error message, so `queue.getJob(id).stacktrace` was always `null` (the TCP job proxy even hardcoded it), DLQ entries had no stack, and any process other than the failing worker could never see it. Reported again on #74 ("I need the stacktrace"). - **`FAIL` now carries the stack** (`stack: string[]`, optional, old clients unaffected). The worker sends the failure's stack lines alongside the error message in both TCP and embedded mode. - **Persisted on the job**: the last failure's stack is stored on the domain job (trimmed lines, capped at `stackTraceLimit`, default 10), survives retries and server restarts (new `jobs.stacktrace` column, migration 13), and rides into the DLQ entry when attempts are exhausted. - **Readable everywhere**: `queue.getJob()` / `getJobs()` now return the real `stacktrace` (TCP + embedded, the proxy no longer hardcodes `null`), DLQ entries expose it via `entry.job.stacktrace`, and fetched jobs also reflect `failedReason` (derived from the persisted timeline). - HTTP `POST /jobs/:id/fail` accepts the same optional `stack` array. - Defensive caps along the wire: client sends at most 50 lines, server accepts at most 100, the job's own `stackTraceLimit` is authoritative. - The worker `failed` event behavior is unchanged (and now covered by regression tests replicating the exact reporter scenario: TCP + auth + cron scheduler + preventOverlap/skipIfNoWorker). ## [2.8.10] - 2026-06-11 ### Fixed: CLI audit: top findings (2 critical + 4 high) A deep CLI audit (same parameter-honoring bug class as the #95 API audit, one layer up) surfaced ~25 issues. This release fixes the critical and high ones: - **A typo no longer boots a server.** The `bunqueue` binary entry point fell through to `startServer()` for any unrecognized first argument, so `bunqueue stast` (typo), `bunqueue version`, `bunqueue doctor` or `bunqueue ping` silently started a full server (bound ports, created the default DB) instead of running the CLI. The server now boots only for a bare `bunqueue`, `start`, or flag-led invocations; everything else routes to the CLI, and unknown commands exit 1 with an error. - **`cron add --max-limit 0` now means unlimited** as the help always said. Previously the server interpreted 0 as "already exhausted" and the cron never fired. Negative values are rejected. - **Global `-t` no longer steals `pull`/`job wait` timeouts.** `bunqueue pull q -t 5000` used to send `Auth { token: "5000" }`; `-t` after `pull`/`job` is now passed through to the subcommand (long `--token` is global everywhere, `-t` before the command still works as token). - **`webhook add` event list matches reality.** It accepted events the server never emits (`job.active`, `job.waiting`, `job.delayed`, webhooks created but permanently dead) and rejected the actually-emitted `job.pushed`/`job.started`. Valid events now: `job.pushed`, `job.started`, `job.completed`, `job.failed`, `job.progress`. - **`bunqueue backup` honors `BUNQUEUE_DATA_PATH`/`BQ_DATA_PATH`** (canonical data-path priority) instead of only `DATA_PATH`/`SQLITE_PATH`. - **Long-poll commands no longer die on the client's own 30s timeout.** For `PULL`/`WaitJob` (only, on PUSH `timeout` is the job execution timeout and does not stretch the client wait) the CLI timeout scales with the command's `timeout` field (+10s buffer), so `pull --timeout 30000` and `job wait --timeout 60000` wait as requested. - **`job wait` that times out now exits 1** with "Job not completed within timeout" instead of printing a green `OK` (exit 0) indistinguishable from success. - **`cron add --every` rejects non-positive intervals.** A negative interval produced a `nextRun` permanently in the past, the cron fired on every scheduler tick, indefinitely. `job wait --timeout` rejects negatives too. - **Global value flags no longer swallow a following flag**: `--token --json`, `-H --json`, `-p --json` now warn and keep `--json` working (same guard `--tls-ca` already had). - Unknown commands and parse errors are now reported without requiring a reachable server (command is built before connecting). **Audit pass 3, parsing, formatters, cross-layer:** - **Entry points unified**: `bunqueue start` now boots the SAME full server as a bare `bunqueue` (shared bootstrap), S3 backup, cloud agent, stats interval, crash handlers and graceful drain were previously missing from the `start` path. Also fixes `HTTP_SOCKET_PATH` being shown in the banner but never applied on the bare entry. - **Short `-h`/`-v` are global only before the command**: `push q '{}' -h host` (typo of `-H`) used to print help and exit 0 without pushing, a false success in scripts. Long `--help`/`--version` stay global; `--help` after `push`/`cron` now shows command-specific help. - **`--` separator**: everything after `--` is opaque to the global parser (no more `--json`/`-t` stolen from values). - **Attached short flags warn**: `push q '{}' -p10` silently dropped the priority and pushed anyway; now a warning points to the separated form. - **Cron `maxLimit` fixed at the domain level**: 0/negative store `null` (unlimited) on EVERY surface, TCP, HTTP API and MCP no longer create permanently-exhausted crons. - **Webhook events validated server-side** against a single canonical list (`WEBHOOK_EVENTS`) shared by CLI, TCP/HTTP handler and MCP, previously the server accepted any string and MCP advertised events that don't exist. - **`WaitJob` timeout capped server-side** (0–600000 ms, like `PULL`), an unbounded wait could hold client and connection for days. - **Formatters stop dropping operational data**: `worker list` shows status (stale workers are now visible), concurrency and job counters; `webhook list` shows enabled state, queue and delivery counters; `cron list` shows next run / max / timezone; `stats` shows uptime and push/pull rates; `webhook add` prints the webhookId (needed for remove); `cron add` prints the next run. - **`job state` of a missing job exits 1** ("Job not found") instead of printing `State: unknown` with exit 0. Remaining low audit findings are tracked for a follow-up release. ## [2.8.9] - 2026-06-10 ### Added: `queue.forward()` store-and-forward + prebuilt binaries **`queue.forward()`**, built-in store-and-forward from a local (edge) queue to a remote bunqueue server. The IoT/edge pattern as a one-liner: ```typescript const fwd = localQueue.forward({ to: { host: 'central.example.com', port: 6789, tls: true, token }, queue: 'ingest', // optional remote name }); ``` - Remote failure → the job fails **locally** (retry with backoff → local DLQ): persist the source queue to survive an uplink outage while its process or volume survives; `retryDlq()` re-enqueues when connectivity returns. - Deduped re-forwards: forwarded jobs carry the deterministic remote jobId `fwd::`, deduped server-side within the custom-id retention window (bounded LRU; remote `removeOnComplete` evicts the entry, for strict exactly-once across long outages, dedupe downstream). - Preserves job name, data and priority; optional `durable: true` for per-job fsync server-side; `forwarded`/`error` events. **Prebuilt binaries**, every release now attaches self-contained executables (no Bun install needed): `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64` + `SHA256SUMS`. Built for edge gateways (Raspberry Pi 4/5, ARM64 boxes): download, untar, run. ## [2.8.8] - 2026-06-10 ### Added: Native TLS (TCP + HTTP) and MQTT bridge example **Native TLS termination**, no reverse proxy needed. Opt-in and fully backward compatible: without cert/key config, both servers behave exactly as before (plaintext). - **Server**: `bunqueue start --tls-cert ./cert.pem --tls-key ./key.pem`, or `TLS_CERT_FILE`/`TLS_KEY_FILE` env vars, or `server.tlsCertFile`/`tlsKeyFile` in `bunqueue.config.ts`. One cert pair covers the TCP server (msgpack protocol, unchanged) and the HTTP server (`https://`/`wss://`). - **Fail fast**: missing cert/key file or a partial config (cert without key) is a startup error, the server never silently falls back to plaintext. - **Client SDK**: `connection.tls` on `Queue`/`Worker`, `true` (system CAs), `{ caFile }` (private CA / self-signed with full verification), or `{ rejectUnauthorized: false }` (dev only). TLS and plaintext pools to the same host:port are never shared. - **CLI client**: `--tls`, `--tls-ca `, `--tls-no-verify` global flags. - Pooled TCP clients no longer crash the process on socket-level errors (e.g. a plaintext client hitting a TLS server): the error is observed and pending commands settle through the close/timeout paths. - New guide: [Native TLS](/guide/tls/). **MQTT → bunqueue bridge example** (`examples/mqtt-bridge/`), IoT/edge recipe: MQTT messages become persisted jobs with retries, DLQ and offline buffering on an edge gateway (embedded SQLite queue), with optional TLS forwarding to a central server. ## [2.8.7] - 2026-06-07 ### Fixed: API audit: HTTP routes and TCP commands now honor every documented parameter (#95 + full audit) A full audit of the HTTP REST API (every endpoint) and the TCP protocol (all 81 commands + client SDK), triggered by #95, surfaced a class of silent bugs where one layer dropped or renamed a parameter so a documented feature was quietly ignored, the call "succeeded" but did the wrong thing. Every confirmed case is fixed and verified by an exhaustive live end-to-end run (91 HTTP checks, all 81 TCP commands) plus unit tests. **HTTP routes** - `GET /queues/:q/jobs/list?status=` ignored the filter, only `state` was read, so it returned the whole queue regardless of the requested state (#95). Now accepts `status`, `state`, and `states`, each repeatable and comma-separated. - `POST /jobs/:id/ack` and `POST /jobs/:id/fail` dropped the `token` (lock ownership) field. - `PUT /jobs/:id/priority` dropped `lifo` (tie-break ordering). - `POST /crons` dropped `immediately`, `skipIfNoWorker`, `preventOverlap`, and `jobOptions`. - CORS headers were missing on `/health`, `/healthz`, `/live`, `/ready`, `/prometheus`, `/gc`, and `/heapstats`, so browser dashboards on another origin could not read them. **TCP protocol / client SDK** - **`ExtendLocks`** (batch lock renewal): the client sent a singular `duration` but the handler reads a per-id `durations[]` array, and read `extended` from a response that returns `count`, batch lock renewal silently kept the old TTL. - **`RetryDlq`**: the client sent `id` but the handler reads `jobId`, so retrying a single DLQ entry retried the **entire** DLQ. - **`PromoteJobs`**: the client read `promoted` from a response that returns `count`, so it always reported 0 promoted jobs. - **`Clean`**: the client sent `type` but the handler reads `state`, so the state filter was ignored. - **`UnrecoverableError` over TCP**: the `unrecoverable` flag on FAIL was dropped server-side, so unrecoverable jobs were retried per their `maxAttempts` instead of failing immediately (worked only in embedded mode). - Worker **`lockDuration`** was never sent as `lockTtl` on PULL, so the server always used its 30s default regardless of the configured value. - **`GetLogs`** ignored the `start`/`end` pagination parameters the client already sent. - Scheduled (cron) jobs dropped **`priority`**. **Counts consistency** - `getJobCounts`/`getStats` undercounted `waiting-children`: jobs blocked on `dependsOn` (`waitingDeps`) report state `waiting-children` and appear in `getJobs({ state: 'waiting-children' })`, but were omitted from the count. The count now matches the reported state and the listed jobs. **Config input validation & hardening** - Config endpoints no longer break on non-numeric input. `SetStallConfig`/`SetDlqConfig` coerce numeric strings and drop non-numeric garbage (so the manager keeps its default) , previously a string `stallInterval` reached numeric comparisons as `NaN` and silently **disabled stall detection** for the queue. `RateLimit`/`SetConcurrency` now reject a non-finite `limit` instead of storing `NaN`. - `PUT /queues/:q/concurrency` now accepts the natural `concurrency` field as well as `limit` (sending `{ "concurrency": N }` previously silently did nothing). - `GET /queues/:q/dlq` supports optional `?limit`/`?offset` pagination and returns `total`. - The `Cron` response now echoes the job `priority`; a single `PULL` no longer sends a redundant batch `count`. ## [2.8.6] - 2026-06-07 ### Fixed: half-open TCP connections now recover off command timeouts, not just the ping (#94) A TCP worker whose socket goes **half-open**, the peer vanishes with no FIN/RST (suspended host, NAT/load-balancer silently dropping an idle connection), had only **one** path back to health: the periodic health-check ping. Every PULL the worker issued timed out (`Command timeout`), `consecutiveErrors` climbed, jobs piled up with `active=0`, and none of those command timeouts ever concluded the link was dead. With default settings the dead socket wasn't torn down until the ping path had failed `maxPingFailures` times, roughly **two minutes**, and if the ping was disabled (`pingInterval: 0`) or slower than real traffic, the worker could stall **indefinitely**. Fixes: - **Command timeouts now drive reconnection.** `maxCommandTimeouts` consecutive in-flight command timeouts with no intervening success (default 3, `0` disables) now conclude the connection is dead and trigger the existing reconnect/backoff path. Recovery no longer depends solely on the health-check ping. The counter resets on any successful response, so it only fires on a sustained run of timeouts, the signature of a dead/half-open socket. Configurable via `connection.maxCommandTimeouts`. - **`forceReconnect()` now settles in-flight commands immediately.** Previously the per-command timers kept ticking after the socket was torn down and could fire against the freshly re-established connection (a reconnect storm); it also made awaiting callers (e.g. a worker's PULL) wait out the full `commandTimeout` on a corpse. They are now rejected at once with `Connection lost`. - **`SO_KEEPALIVE` enabled** on client sockets so the OS surfaces a dead peer on its own rather than waiting out `tcp_retries2` (~15 min). Best-effort, platform-dependent. - Hardened `socket.end()` in the reconnect path against throwing on an already-dead socket. Note: with a default `commandTimeout` of 30s, timeout-based detection is still inherently coarse (each timeout is 30s). For fast recovery, lower `connection.pingInterval` / `connection.commandTimeout`; the new path also makes recovery work when the ping is off. ## [2.8.5] - 2026-06-05 ### Fixed: write buffer no longer drops unrelated jobs on a duplicate id (data loss) A duplicate `jobs.id` (the global PRIMARY KEY) poisoned the entire atomic write-buffer flush: the failing batch rolled back as a whole, was retried, and after exhausting retries **every job in that flush window was dropped, including unrelated, valid jobs** that merely happened to be batched together. Silent, unrecoverable, and triggerable by a single duplicated custom `jobId`. Two ways to hit it: - the **same custom `jobId` in two different queues** sharing one database, or - **reusing a custom `jobId` after its job completed**: `markCompleted` UPDATEs the row (it survives), so the reused, deterministic id collided with it. Fixes: - **Per-row isolation on flush.** When the fast atomic batch INSERT fails, the buffer now re-inserts row by row: valid jobs persist, a constraint violation (e.g. duplicate id) is isolated and dropped (it can never succeed, so it is no longer retried, which is what poisoned every subsequent flush), and genuinely transient failures (disk I/O, full) are retried exactly as before. The success path is unchanged (a single transaction). - **Completed-id reuse evicts the stale job.** Re-adding a completed custom `jobId` now evicts the old completed record (row, result, in-memory state) so the new job starts fresh as `waiting` instead of colliding (and `getJobState` no longer returns `completed` for the brand-new job). Note: when the same global `jobs.id` is genuinely duplicated across queues, the losing duplicate is dropped from disk (it cannot be persisted twice), it still lives in memory until restart. This is the correct trade-off and is strictly safer than the previous behavior, which dropped the unrelated jobs instead. ### Fixed: transient SQLite IOERR during PRAGMA setup no longer crashes startup Optimization PRAGMAs (e.g. `mmap_size`, which calls `fstat()` on the fd) are applied with error handling: a transient filesystem `SQLITE_IOERR` during a restart/cleanup race is now caught and logged instead of propagating out of the `SqliteStorage` constructor (where, in a deferred context, it surfaced as an "Unhandled error between tests" and tore down CI). ## [2.8.4] - 2026-06-05 ### Fixed: `getJobCounts()` / per-state lists now agree with the real state (#92) Two ways the counts and the per-state lists could disagree: - **Failed jobs were not enumerable on the storage path.** A job that exhausts its attempts is moved to the `dlq` table and its `jobs` row is removed, so `getJobs({ state: 'failed' })` / `getFailedAsync()` ran `SELECT … FROM jobs WHERE state='failed'` and came back empty, even though `failed` counted it, `getJobState()` returned `'failed'`, and `getJob(id)` found it (standalone server, and embedded with a `dataPath`). The storage path now also reads the DLQ for `'failed'`, mirroring the in-memory path. The unfiltered `getJobs()` likewise includes failed jobs. - **`pause()` double-counted.** A single ready job was reported under **both** `waiting` and `paused`, while `getJobs({ state: 'paused' })` returned `[]`. Now follows BullMQ semantics: a paused queue reports its ready jobs (waiting **and** prioritized) under `paused`, with `waiting: 0` / `prioritized: 0`, and lists them via `getJobs({ state: 'paused' })`. A job is never counted in two buckets at once. Applied consistently across the client SDK, the TCP `GetJobCounts` handler, and the dashboard detail endpoint (which also gains a `paused` job list). Also fixed a pagination defect surfaced by the DLQ merge: offset-unaware sources (DLQ, paused/waiting-children views) are now gathered from index 0, merged, and sliced once, so paged queries no longer duplicate or drop rows. **Behavior change:** `getJobCounts()` on a paused queue previously returned the waiting count under _both_ `waiting` and `paused`; it now returns `waiting: 0, paused: N`. The monitoring aggregate `getStats()` (and `/stats` / Prometheus) keeps reporting physical counts and is unaffected. ### Fixed: honest Bun-only packaging with a clear Node error (#93) `package.json` declared `engines.node >= 18`, but the client cannot run on Node: the published ESM uses directory/extensionless specifiers (Node's resolver rejects them with `ERR_UNSUPPORTED_DIR_IMPORT`) and the TCP transport relies on Bun globals (`Bun.connect`, `Bun.file`, `Bun.hash`, …) with no Node fallback. - Dropped `node` from `engines` (now `bun >= 1.3.9` only). - Added a `"bun"` export condition (the real entry) and a `"node"` condition pointing at a single self-contained stub on every subpath (`.`, `./client`, `./queue`, `./mcp`, `./workflow`). Importing from Node now fails fast with a clear _"bunqueue is Bun-only…"_ error instead of a cryptic resolver crash; Bun resolves the real entry unchanged. - Added a runtime guard for the bundled path (browser/neutral-target bundle run on Node). ## [2.8.3] - 2026-06-03 ### Fixed: expose `./package.json` in `exports` With the `exports` map defined, `require('bunqueue/package.json')` (and `import` of the same subpath) failed with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Some tools read a dependency's `package.json` directly (e.g. to detect the installed version). Added `"./package.json": "./package.json"` to `exports`. No other change; all existing subpath exports (`.`, `./client`, `./queue`, `./mcp`, `./workflow`) are unaffected. ## [2.8.2] - 2026-06-02 ### Stop shipping source maps: another −34% off the install The published package included `*.js.map` and `*.d.ts.map` source maps (512 files, 2.8 MB) whose `sources` point at `src/*.ts`, which is **not** shipped in the package. With no source to resolve against, those maps were dead weight on every install. Disabled `sourceMap`/`declarationMap` in `tsconfig.build.json` so tsc emits neither the maps nor the trailing `sourceMappingURL` comments (no dangling references), and dropped the now-empty `*.map` globs from `files[]`. | Metric | 2.8.1 | 2.8.2 | Delta | | ------------------- | ------ | ------ | ------------------ | | `node_modules` size | 8.2 MB | 5.4 MB | **−2.8 MB (−34%)** | | `bunqueue` package | 5.8 MB | 3.0 MB | −48% | | files in package | 1027 | 503 | −524 | | tarball (download) | 664 KB | 409 KB | −38% | No runtime change. Cumulative since 2.7.x: a clean `bun add bunqueue` went from **94 MB / 117 packages to 5.4 MB / 7 packages (−94%)**. ## [2.8.1] - 2026-06-02 > Released as 2.8.1 because 2.8.0 was already taken on npm (an earlier accidental publish, since deprecated). Same changes as intended for 2.8.0. ### Slimmer install: −91% `node_modules` for queue users (MCP SDK is now an optional peer dependency) bunqueue shipped `@modelcontextprotocol/sdk` and `zod` as hard runtime dependencies, so **every** consumer, including the majority who only use the job queue, downloaded the MCP server's entire toolchain (the SDK, `zod`, and an HTTP stack of `express`, `hono`, `ajv`, `jose`, `cors`, …). On top of that, `bun` was declared as a `peerDependency`, which made package managers pull the **61 MB `bun` runtime package** into the consumer's tree. This release makes the MCP dependencies opt-in without removing any feature: the `bunqueue-mcp` binary and the `bunqueue/mcp` export still ship, but the SDK is now an **optional peer dependency** loaded lazily via dynamic `import()`. Queue users pay nothing for a feature they don't use. #### Benchmark: `bun add bunqueue` in a clean project (measured) | Metric | 2.7.x | 2.8.0 | Delta | | --------------------------- | ------- | ------ | ----------------- | | `node_modules` size | 93 MB | 8.2 MB | **−85 MB (−91%)** | | Installed packages | 117 | 7 | **−110 (−94%)** | | Cold install time | 2.73 s | 0.72 s | **3.8× faster** | | `@modelcontextprotocol/sdk` | bundled | absent | opt-in | | `zod`, `express`, `hono` | bundled | absent | removed | | `bun` runtime package | 61 MB | absent | removed | Breakdown of the ~85 MB saved: **~61 MB** from dropping the `bun` peer dependency, **~24 MB** from making the SDK + `zod` + HTTP stack optional. #### Migration - **Queue users** (`bunqueue/client`, `Queue` / `Worker` / `Workflow`, `bunqueue/queue`, `bunqueue/workflow`), **no action required.** The public bundles contain zero SDK/`zod` code; your install simply gets smaller. - **MCP users** (`bunqueue-mcp` or `import 'bunqueue/mcp'`), install the SDK once in the environment where the server runs: ```bash bun add @modelcontextprotocol/sdk ``` `bunx --package=bunqueue bunqueue-mcp` does **not** auto-install optional peer dependencies. If the SDK is missing, the launcher fails fast with an actionable message and exit code 1: ``` [bunqueue-mcp] The MCP server requires "@modelcontextprotocol/sdk" (an optional peer dependency). Install it with: bun add @modelcontextprotocol/sdk ``` #### Breaking (MCP only) Setups that relied on the SDK being installed transitively must now add `@modelcontextprotocol/sdk` explicitly. **The queue / worker / workflow API is unchanged**, this is the only reason the release is a minor and not a patch. #### Implementation notes - `src/mcp/index.ts` is now a thin launcher; the server implementation moved to `src/mcp/server.ts` (`export async function run()`), keeping the SDK out of the entrypoint's static import graph so it can be optional. - `@modelcontextprotocol/sdk` → `peerDependencies` + `peerDependenciesMeta.optional` (also kept in `devDependencies` for build/test). `zod` removed from `dependencies` (it ships with the SDK; pinned in `devDependencies` for builds). - `bun` removed from `peerDependencies`; `engines.bun` aligned to `>=1.3.9`. Declaring a runtime as a peer triggers spurious resolution warnings under npm/pnpm/yarn, `engines` is the correct field. - `webhookTools` switched `z.url()` → `z.string().url()` for compatibility across the SDK's accepted `zod` range (`^3.25 || ^4.0`). #### Verification `build:lib` clean · `tsc --noEmit` clean · 181 MCP tests pass · full unit suite (5,479) green · non-MCP bundles verified free of SDK/`zod` · peer-optional confirmed **not** installed by both `bun` and `npm` in a clean project. Thanks to **@tmvc03** ([#90](https://github.com/egeominotti/bunqueue/discussions/90)) for reporting the footprint and proposing both the MCP split and the `peerDependencies` → `engines` change. ## [2.7.22] - 2026-06-02 ### Fixed (CI: broken transitive publish of `typescript-eslint@8.60.1`) CI lint job failed with `error: No version matching "8.60.1" found for specifier "@typescript-eslint/types" (but package exists)`. Root cause: `bun.lock` is gitignored and CI runs `bun install` without `--frozen-lockfile`, so every run does a fresh, non-reproducible resolve. The dev dependency was declared `typescript-eslint: "^8.56.1"`, which floated up to `8.60.1`, an upstream release whose meta-package was published before its sub-packages (`@typescript-eslint/types`, `@typescript-eslint/scope-manager`) propagated, leaving a window where fresh installs couldn't resolve them. npm has since healed. - **Pinned `typescript-eslint` to exact `8.56.1`** (dropped the `^` caret) in `package.json` so CI no longer floats into a broken or unexpected upstream release. Lint-only devDependency; zero runtime impact. All three suites pass (5479 unit, 59 TCP, 36 embedded). ## [2.7.21] - 2026-06-02 ### Fixed (docs: `bunx bunqueue-mcp` 404, #91) `bunqueue-mcp` is a binary **bundled inside** the `bunqueue` package, not a standalone npm package. Running `bunx bunqueue-mcp` (or `npx bunqueue-mcp`) without `bunqueue` installed made the launcher try to download a package named `bunqueue-mcp`, which doesn't exist → `error: GET https://registry.npmjs.org/bunqueue-mcp - 404`. The runtime is unchanged; this is a docs/invocation fix. - **MCP setup docs now make the install step explicit**: every guide (README, MCP guide, quickstart, server, cron, use-cases) shows `bun add bunqueue` (or `bun add -g bunqueue`) before `bunx bunqueue-mcp`, and a caution box explains the 404. - **All JSON MCP configs switched to `args: ["--package=bunqueue", "bunqueue-mcp"]`**: copy-paste safe: `bunx` resolves the bundled binary straight from the `bunqueue` package with no separate install. The skill configs use the same form; `npx` was replaced with `bunx` (the MCP entry's shebang is `#!/usr/bin/env bun`). - **Removed the misleading `bunx bunqueue-mcp --help`** from troubleshooting, the MCP entry doesn't parse CLI args (it starts the stdio server immediately). - **Repo `.mcp.json` now runs the local source** (`bun run src/mcp/index.ts`) instead of fetching from npm. ## [2.7.20] - 2026-05-31 ### Fixed (live full-feature E2E audit: 3 bugs surfaced by hands-on testing) A live end-to-end pass exercised every feature locally (18 areas, ~317 checks); all 2.7.19 fixes held, and three pre-existing bugs were found and fixed. Each ships a reproducing test (`test/audit-*.test.ts`). - **`drain()` left stale rows in SQLite (embedded)**: `queue.drain()` cleared the in-memory index and counts but never deleted the SQLite rows, so drained jobs resurrected via `getJobState`/`getJob`/`getWaiting`/`getJobs` and would reload on restart. `drainQueue` now deletes each drained job's row (via the same `safeDeleteJob` path `clean`/`obliterate` use, which also clears any pending write-buffer entry). Only waiting/delayed/prioritized jobs are drained (active jobs untouched). (`application/operations/queueControl.ts`) - **Workflow `waitFor` timeout ran saga compensation twice**: on a `waitFor` timeout, `runWaitFor()` compensated and then threw a plain `Error`, which `processStep`'s catch re-compensated. It now throws the `WaitForSignalError` sentinel (and emits `workflow:failed` once) so compensation runs exactly once. The signal-success path, normal step-failure path and `forEach` compensation are unchanged. (`client/workflow/executor.ts`) - **Auto-batch `add()` swallowed server rejections**: when the server rejected a `PUSHB` (e.g. auth failure), `addBulk` returned `[]` and the auto-batcher resolved callers with `undefined` instead of throwing, so a batched `queue.add()` silently appeared to succeed (the server correctly persisted nothing, this was an error-propagation defect, not an auth bypass). `addBulk` now throws on `!response.ok` (mirroring the single-`PUSH` path), so all batched callers reject; an OK-but-empty response still returns `[]`. (`client/queue/operations/add.ts`) ## [2.7.19] - 2026-05-31 ### Fixed (stability audit: 13 confirmed failure-path bugs, each with a reproducing test) Happy-path behaviour was already solid; these harden bunqueue under failure, stress, attack, restart and long-running conditions. Each fix ships with a `test/audit-*.test.ts` that reproduced the bug (red) and now passes (green). - **Cloud snapshots leaked unredacted job data (security)**: `BUNQUEUE_CLOUD_REDACT_FIELDS` was applied only to the event stream, never to periodic snapshots, so raw `job.data` and DLQ `jobData` (potential PII/secrets) were sent to the dashboard. Redaction (and `includeJobData` gating) is now threaded through the snapshot path via a shared `redact` helper. (`cloud/snapshotHelpers.ts`, `snapshotCollector.ts`, `cloudAgent.ts`, new `cloud/redact.ts`) - **WriteBuffer critical loss was unrecoverable after restart**: when a flush exhausted its 10 retries, lost jobs were only logged + kept in an in-memory cap; they are now persisted to the DLQ (direct DB write, no recursion into the failed buffer) so they survive a restart. (`persistence/sqlite.ts`) - **Corrupt `dependsOn` blob ran jobs out of order**: a MessagePack decode failure silently returned empty deps, so a job recovered with corrupt dependency metadata executed as if it had none. Corruption is now flagged with a collision-proof `Symbol` and the job is routed to the DLQ on recovery instead of running. (`persistence/sqliteSerializer.ts`, `application/backgroundTasks.ts`) - **Worker ACK batcher silently dropped ACKs on overflow**: at the pending-ACK cap the oldest ~10% were discarded without being sent, leaving those jobs stuck `processing` and requeued indefinitely. Overflow now applies backpressure (awaits a flush) instead of dropping. (`worker/ackBatcher.ts`) - **TCP slowloris / per-connection memory exhaustion**: a partial frame had no read timeout. A per-connection stall timer (armed only while a partial frame is buffered, `TCP_IDLE_TIMEOUT_MS`, default 60s) now reaps stalled connections; single frames remain bounded by `maxFrameSize`. Legitimate 4–64MB frames delivered across TCP segments are unaffected. (`server/protocol.ts`, `server/tcp.ts`) - **TCP responses dropped under backpressure**: `socket.write()` short-writes were ignored and `drain()` was a no-op. A per-socket write queue now buffers unwritten bytes (order-preserving), flushes on `drain()`, and caps at `TCP_MAX_WRITE_QUEUE_BYTES` (default 64MB, drops the connection past the cap). (`server/tcp.ts`, new `server/socketWriteQueue.ts`) - **TCP client hung on a malformed frame (pipelining)**: only the legacy `currentCommand` was rejected; all in-flight pipelined commands hung until timeout. A malformed frame now rejects every in-flight command and force-reconnects. (`client/tcp/client.ts`) - **Flow-failure tracking maps grew unbounded**: `failedChildrenValues`/`ignoredChildrenFailures` were never cleared on normal parent completion or in `shutdown()` (only on `obliterate`). They are now released when the parent reaches a terminal state and cleared on shutdown. (`application/queueManager.ts`) - **`forEach` saga compensation lost iteration context**: compensate handlers couldn't tell which item they were rolling back (`__item`/`__index` weren't restored). Each iteration's item/index is now persisted on its step record and restored into the compensation context. (`workflow/loops.ts`, `compensator.ts`, `types.ts`) - **Re-created cron silently skipped its first fire**: `lastFiredAt` wasn't cleared on `remove()`/upsert, so a same-named cron hit stale overlap detection. It is now cleared on remove/upsert. (`scheduler/cronScheduler.ts`) - **Interval cron drift**: `repeatEvery` `nextRun` was computed from execution time (`now + interval`), drifting on late runs; it is now anchored to the scheduled slot (fixed-rate). (`scheduler/cronScheduler.ts`) - **S3 restore could corrupt/delete the live DB**: restore wrote over the live database before validating, and a failed integrity check unlinked it. Restore is now atomic: write to temp → validate → rename; the live DB is never touched on failure. (`backup/s3BackupOperations.ts`) - **DLQ exceeded `maxEntries` after restart**: `restoreEntry()` skipped the eviction `add()` performs; it now enforces `maxEntries` (oldest-first) on recovery. (`domain/queue/dlqShard.ts`) ## [2.7.18] - 2026-05-31 ### Fixed (option-forwarding audit, follow-ups to #88) - **`getJobsAsync()` (and `getWaitingAsync`/`getDelayedAsync`/`getActiveAsync`/`getCompletedAsync`/`getFailedAsync`) dropped `job.opts` over TCP**: listed jobs returned `opts: {}`, so `job.opts.attempts`/`timeout`/etc. were `undefined`, while `getJob(id).opts` was correct. The server already sends the full job; the client now reflects the complete `opts` via `metaFromJob`. This closes the slim-`opts` limitation noted in 2.7.17. - **Returned Job hardcoded `deduplicationId`/`parentKey`/`parent`/`repeatJobKey`**: `createJobProxy`/`createSimpleJob` set these to `undefined` even when known at call time, diverging from embedded mode. They are now derived from the requested options (shared `reflectFields`), matching `buildJobProperties`. - **`FlowProducer` silently dropped extended job options**: flow nodes ignored `lifo`, `deduplication`, `durable`, `stallTimeout`, `stackTraceLimit`, `keepLogs`, `sizeLimit`, `repeat`, `timestamp` and `debounce` in **both** embedded and TCP modes (`durable: true` being ignored meant a critical flow job used buffered writes instead of immediate persistence). `flowPush` now forwards the full option set, mirroring `Queue.add`. - **`job.toJSON()`/`asJSON()` hardcoded `opts: {}` and `delay: 0`**: the BullMQ-compatible serializers on a TCP/bulk-created Job lost the reflected options. They now reflect `opts`, `delay` and `parentKey`. - **`changePriority({ priority, lifo })` silently dropped `lifo`**: the option was accepted by the type but never applied (the engine had no way to honor it). `lifo` is now threaded end-to-end: `ChangePriorityCommand` → server handler → `queueManager.changePriority` → `jobManagement.changeJobPriority` → `priorityQueue.updatePriority` (updates the tie-break flag). Forwarded from all SDK surfaces: `Queue`, the job proxies, the in-processor job handler, and `FlowProducer` job nodes. ### Changed - **`JobOptions.removeOnComplete`/`removeOnFail` narrowed to `boolean`**: the previously documented `number | KeepJobs` (age/count retention) forms were never implemented and were silently coerced inconsistently (embedded kept the job, TCP removed it immediately for the same input). The type now rejects the unsupported forms at compile time, the single-`PUSH` path coerces for embedded/TCP parity, and the server hardens `parseCoreOptions` with `Boolean()`. (Worker-level `removeOnComplete`/`removeOnFail` defaults are unaffected.) ## [2.7.17] - 2026-05-30 ### Fixed - **Created job has wrong priority / options not reflected in TCP mode (#88)**: `await queue.add(name, data, { priority: 10 })` returned `job.priority === 0` over TCP. The Job object returned by `add()`/`addBulk()` (and `getJob()`/`getJobs()`) is built client-side by `createJobProxy`/`createSimpleJob`, which hardcoded `priority: 0`, `delay: 0`, `opts: {}`. These now reflect the requested/stored options (`priority`, `delay`, `opts`). Embedded `add()` was already correct (it uses `toPublicJob`). - **TCP `add()` silently dropped job options**: the single-job TCP `PUSH` path forwarded only a subset of options, so `deduplication`, `ttl`, `tags`, `groupId`, `lifo`, `keepLogs`, `sizeLimit`, `stackTraceLimit`, `debounce`, `dependsOn`, `failParentOnFailure`, `removeDependencyOnFailure`, `continueParentOnFailure`, `ignoreDependencyOnFailure` and `timestamp` were ignored when adding a single job over TCP. The `PUSH` command and its handler now carry and apply the full option set, matching embedded mode and bulk add. `addBulk` forwarding gaps (`removeOnComplete`/`removeOnFail`, parent, dedup, tags, groupId, dependsOn) were closed too. ### Changed - TCP job payloads now omit `undefined`-valued keys, keeping large bulk frames compact (a 1000-job bulk payload dropped from ~446 KB to ~320 KB), which also avoids an intermittent large-frame delivery stall under load. ### Notes - `getJobsAsync()` returns a slim `opts` (`{}`) for listed jobs, whereas `getJob()` returns the full `opts`. The reflected `delay` tracks current scheduling (`runAt - createdAt`), so after a retry/backoff it reflects the next run, not the originally requested delay. ## [2.7.16] - 2026-05-29 ### Fixed - **MCP returns inconsistent numbers across monitoring tools (#87)**: In TCP mode the MCP `TcpBackend` parsed several TCP response envelopes at the wrong nesting level, so monitoring tools returned wrong or empty data even though the CLI (which parses correctly) worked. Fixed: `bunqueue_get_job_counts` now reads `res.counts.*` (was reading top-level → always `0`); `bunqueue_list_workers` reads `res.data.workers` with the correct field names (`processedJobs`/`failedJobs`/`lastSeen`) and no longer returns `[]` for a registered worker; `bunqueue_get_jobs` maps the tool's `start`/`end` to the protocol's `offset`/`limit` so pagination works (previously `start` was ignored and the page defaulted to 100 instead of the requested size); `bunqueue_get_per_queue_stats` now uses the `DashboardQueues` command for a real per-queue breakdown (`{waiting, prioritized, delayed, active, dlq}`) instead of global `Metrics`, matching embedded mode. The `DashboardQueues` handler now also forwards `prioritized`. ### Notes - `bunqueue_get_counts_per_priority` counts only waiting/delayed (queued) jobs, active, completed and failed jobs are not included. The tool description now states this explicitly. ## [2.7.15] - 2026-05-26 ### Fixed - **Cron/scheduler jobs ignored job options (#86)**: Jobs spawned by `upsertJobScheduler`/cron always used `JOB_DEFAULTS` (`maxAttempts: 3`, `removeOnFail: false`), ignoring both the scheduler job template `opts` and the Queue `defaultJobOptions`. A scheduler with `attempts: 1, removeOnFail: true` still retried 3× and landed failed jobs in the DLQ. Cron definitions now carry a `jobOptions` field (`maxAttempts`, `backoff`, `timeout`, `delay`, `stallTimeout`, `removeOnComplete`, `removeOnFail`) that `fireCronJob` forwards into each spawned job. The client merges Queue `defaultJobOptions` (base) with per-scheduler template `opts` (override), mapping `attempts` → `maxAttempts`. Persisted via new `job_options` column (schema migration 12); old rows load as `null` and fall back to defaults. ### Notes - For cron jobs, `removeOnComplete`/`removeOnFail` honor only the boolean form. The numeric/`KeepJobs` variants accepted by `queue.add()` are not applied to scheduler-spawned jobs and fall back to `false`. - A per-job `delay` set in scheduler options stacks on top of the cron fire time (the spawned job is delayed `delay` ms after each scheduled fire). ## [2.7.14] - 2026-05-15 ### Fixed (CLI audit, 8 bugs) - **`worker register` via CLI silently expires**: Server auto-unregisters workers when their TCP connection closes; one-shot CLI commands disconnect immediately, so `worker list` right after `worker register` showed nothing. CLI now prints a stderr warning explaining transience and pointing users to the SDK `Worker` class for persistence. - **`pull` displayed `State: unknown`**: Server-side `Job` doesn't carry an explicit `state` field (state lives in `jobIndex`), so the PULL response omitted it. `src/cli/output.ts` now derives state from timestamps: `completedAt` → completed, exhausted retries (`attempts >= maxAttempts && startedAt > 0`) → unknown (since it could be DLQ), `startedAt > 0` → active, `runAt > now` → delayed, else waiting. Zero-signal jobs (no timestamps) still display `unknown` rather than a confident guess. - **`job progress` and `job delay` errors conflated "not found" with "not active"**: Both handlers (`management.ts` Progress, `advanced.ts` MoveToDelayed) returned the literal string `Job not found or not active`. They now query `getJobState` on failure and emit either `Job not found` or `Job is not active (current state: X)`, so operators can act on the distinction. - **Client ignored env vars `TCP_PORT`/`HOST`**: Server reads `TCP_PORT`, `HTTP_PORT`, `HOST`; CLI client only honored `--port`/`--host`. Asymmetric. Client now reads `TCP_PORT` (primary, matches server) plus `BUNQUEUE_TCP_PORT`/`BQ_TCP_PORT` aliases for `HOST` too. Priority: explicit CLI flag > env > default. - **`queue clean` output said `Created 0 jobs`**: Batch-id formatter used a single "Created" verb for all responses with `ids` arrays. Now context-aware: `push` → `Created`, `queue clean` → `Cleaned`, `queue drain` → `Drained`, `dlq retry` → `Retried`, `dlq purge` → `Purged`. Falls back to `Affected` for unknown contexts. - **`job result` printed literal `Result: undefined`**: When a job's result is `undefined`/`null` (job not completed or `removeOnComplete: true`), CLI now shows `No result available (job not completed or result was removed)` instead of stringifying undefined. - **Short flags `-h` / `-v` triggered server start instead of help/version**: Global parser treated unknown short args as server flags. `-h` now aliases `--help`, `-v` aliases `--version` (server's existing `-H`/`-p`/`-t` short flags unchanged). ### Tests - New `test/cli-issues.test.ts`, 11 reproducer tests covering each of the 8 CLI bugs above (subprocess-spawn approach with a real server on a dedicated port). - Updated `test/server-handlers-core.test.ts`, 4 callsites converted to `await` after `handleGetProgress` became async (needed for state disambiguation). ### Internal - `formatOutput` and `formatSuccess` (`src/cli/output.ts`) now accept an optional `subcommand` arg so batch-id responses can pick the right verb. - `handleGetProgress` (`src/infrastructure/server/handlers/management.ts`) changed signature from sync `Response` to async `Promise` to support disambiguation via `getJobState`. ## [2.7.13] - 2026-05-15 ### Fixed - **WriteBuffer silent data loss when retries exhausted**: `SqliteStorage` previously constructed `WriteBuffer` without an `onCriticalError` callback, so jobs dropped after `maxRetries` (10) vanished with no recovery path (the retry-exhaustion branch now lives at `writeBuffer.ts:95-105`). `SqliteStorage` now wires a default handler that logs every lost job (id/queue/customId/priority/data preview), retains the last 100 critical-loss records in memory, and forwards to an optional user `onCriticalLoss` config callback. New API: `storage.getCriticalLosses()` / `storage.clearCriticalLosses()`. - **`AsyncLock`/`RWLock` double-release broke mutual exclusion**: `guard.release()` had no idempotency check; a stale double-release could clobber the next owner's `locked=true` flag and let two acquirers run concurrently in the critical section, violating the documented lock hierarchy (jobIndex → completedJobs → shards[N] → processingShards[N]). All three guards (`AsyncLock`, `RWLock` read, `RWLock` write) now track a per-guard `released` flag and short-circuit subsequent calls. - **State-transition writes raced with buffered INSERTs**: `markActive`/`markCompleted`/`markFailed` ran synchronously while the corresponding `insertJob` was still in the 10ms-batched `WriteBuffer`. The `UPDATE` matched 0 rows silently, then the buffered `INSERT` later wrote with the stale insert-time state (`waiting`/`delayed`), clobbering intent. Added `WriteBuffer.hasPending(jobId)` and a private `SqliteStorage.flushIfBuffered(jobId)` helper invoked at the top of every state-mutating method so the row exists on disk before the `UPDATE` runs. If flush fails, in-memory state stays authoritative and the new critical-loss callback records the dropped jobs for log-based recovery. - **TCP close handler orphaned jobs and leaked `clientJobs` Map entries on retry exhaustion**: `tcp.ts` close handler called `releaseClientJobsWithRetry` (3 attempts with exponential backoff); on persistent lock contention only logged, leaving (a) the `clientJobs` Map entry uncleared (leaks across flapping reconnects) and (b) jobs stuck in `active` state until the full stall timeout (~30s). `clientTracking.releaseClientJobs` now wraps the locked release block in `try/finally` so the Map entry is always deleted. New `forceReleaseClientJobs(clientId)` performs a lock-free best-effort cleanup: clears `clientJobs`, drops orphaned `jobLocks` tokens, and expires both `lastHeartbeat=0` and `startedAt=0` so the stall detector recovers within ~2 ticks (~10s with defaults). `tcp.ts` close handler invokes it in the catch branch as a last-resort fallback. - **`SandboxedWorker` `Cannot find module` flake on macOS**: Tests using `Bun.write` to create processor files (no fsync) followed by `Worker` spawn could fail with `ModuleNotFound` because the file wasn't yet visible to the fresh Worker process. `createWrapperScript` in `src/client/sandboxed/wrapper.ts` now polls for processor visibility (up to 20×5ms), normalizes the path (removes `TMPDIR`-trailing-slash double slashes), and resolves symlinks (`/var` → `/private/var` on macOS) so the wrapper's `await import(...)` sees the same path Bun's module loader uses. - **`Client closed` unhandled rejection on intentional TCP shutdown**: `TcpClient.close()` calls `commands.rejectAll(new Error('Client closed'))`, which rejected any in-flight Promises. Callers without a `.catch` in place at that exact microtask (fire-and-forget heartbeats, polling loops mid-await, chained-Promise patterns) produced unhandled rejections and a non-zero process exit during graceful shutdown, causing TCP integration suites (`test-sandboxed-worker.ts`) to fail despite the test cases themselves passing. `connection.ts` `rejectAll` now attaches a silent `.catch` on each command's tracked Promise reference (new `PendingCommand.promise` field) before rejecting. A one-shot `process.on('unhandledRejection')` filter in `TcpClient.close()` catches the rare chained-Promise leak whose `.catch` lives further down the chain. ### Tests - 4 new reproducer files (13 tests) covering each fixed bug: - `test/bug-writebuffer-no-critical-callback.test.ts` (3 tests) - `test/bug-asynclock-double-release.test.ts` (4 tests) - `test/bug-state-transition-before-buffer-flush.test.ts` (4 tests) - `test/bug-tcp-orphan-jobs-on-release-failure.test.ts` (3 tests, including `jobLocks` drop + `startedAt=0` invariants) ### Internal - `WriteBuffer.hasPending(jobId)`, O(n) linear scan over active + flush buffers (max 200 iters at default size 100). Hot-path overhead acceptable for default 10ms batching; if benchmarks show regressions, switch to a `Set` mirror. - `SqliteStorage` constructor accepts new `onCriticalLoss?: (jobs, error, attempts) => void` callback. - `QueueManager.forceReleaseClientJobs(clientId): number`, synchronous, returns count of jobs whose state was reset. - `PendingCommand.promise?: Promise<...>`, optional reference to the caller-visible Promise so `rejectAll` can attach silent `.catch`. ## [2.7.12] - 2026-05-11 ### Internal - Remove 63 unnecessary `as Type` assertions across `src/` flagged by `@typescript-eslint/no-unnecessary-type-assertion` on CI's stricter `@types/bun@1.3.13`. Pure type-level cleanup, no runtime impact. - Refactor `src/cli/output.ts` `str()` to narrow `unknown` via explicit `typeof` branches and a `{ toString(): string }` interface cast, avoiding both `no-unnecessary-type-assertion` and `no-base-to-string` rule conflicts. ## [2.7.11] - 2026-05-11 ### Fixed - **`defineConfig` caused "Failed to listen at 0.0.0.0" when used in config file** (Issue #85, reported by @timnew), `src/main.ts` re-executes its top-level dispatch on every import. Running `bunqueue start -c typed.ts` started the server, then `loadConfigFile()` imported the user config which imports `defineConfig` from `'bunqueue'` → resolves to `dist/main.js` → top-level code sees `argv[2] === 'start'` and re-invokes the CLI, attempting a second bind on the same port. Wrapped the top-level CLI/server dispatch and the Logger env-var bootstrap in `if (import.meta.main)` so importing the package entry (for `defineConfig` or other re-exports) has no side effect. Behavior when `src/main.ts` is the actual entry (e.g. `bun run src/main.ts`, compiled binary) is unchanged. ### Tests - New regression test `test/issue-85-config-import-side-effect.test.ts`, spawns a subprocess that imports `src/main.ts` with `process.argv` emulating `bunqueue start`, asserts no server banner/bind logs. ## [2.7.10] - 2026-04-20 ### Fixed - **`clean()` left orphan rows in `job_results` table** (Issue #84, follow-up from @jdorner), `storage.deleteJob()` executed only `DELETE FROM jobs`, so cleaned completed jobs' result rows persisted forever in `job_results`. `deleteJob()` now runs both `DELETE FROM jobs` and `DELETE FROM job_results WHERE job_id = ?` inside a single `db.transaction(...)` block, atomically cascading the removal. DLQ is intentionally not cascaded here: `moveFailedJobToDlq()` relies on `saveDlqEntry` + `deleteJob` preserving the DLQ row. Callers that clean DLQ (e.g. `cleanFailed`) explicitly call `deleteDlqEntry` beforehand. ### Added - `deleteJobResult` prepared statement in `src/infrastructure/persistence/statements.ts`. ### Tests - 2 regression tests in `test/client-queue-operations.test.ts`: clean('completed') leaves no orphan `job_results` rows; clean('failed') leaves no orphan rows in jobs/dlq/job_results. - Updated `test/sqlite-serializer.test.ts` statement count (13 → 14). ## [2.7.9] - 2026-04-20 ### Fixed - **`clean()`/`cleanAsync()` returned array of empty strings** (Issue #84, follow-up from @jdorner), Previously returned `new Array(count).fill('')`, so the result length was correct but the IDs were empty. Now returns the actual `JobId[]` of removed jobs end-to-end (queueControl → queueManager → TCP handler → MCP adapter → cloud commands → client). - **Completed jobs lost after server restart** (Issue #84, follow-up from @jdorner), `recover()` did not repopulate `jobIndex`/`completedJobs`/`completedJobsData` for completed jobs in SQLite, so `cleanAsync('completed')` after a restart found nothing to clean and `stats.completed` under-reported. Added Phase 3 recovery: loads up to `maxCompletedJobs` (default 50k) jobs ordered by `completed_at DESC`, populates in-memory indexes. Does not touch `customIdMap` (preserves pending-job dedup). ### Added - SQLite migration 11: `idx_jobs_completed_order` index on `(completed_at DESC) WHERE state = 'completed'` for O(log n) recovery ordering. ### Protocol - `CountResponse` now carries an optional `ids?: string[]` field, populated by the `Clean` handler so TCP clients receive the removed job IDs (previously only the count). ### Tests - 2 new regression tests in `test/client-queue-operations.test.ts` (actual-ids returned, post-restart cleanup). - Updated 8 obsolete tests that asserted `clean()` returned a number. - Updated `stress.test.ts` persistence-under-load expectation from 100 → 200 (completed jobs now survive restart, so cumulative total is correct). ## [2.7.8] - 2026-04-20 ### Fixed - **`cleanAsync()` silently returned `[]` for `completed`/`failed`/`wait`** (Issue #84), `cleanQueue()` only handled `'waiting'` and `'delayed'` state filters; all other states fell through to a no-op, leaving job data in SQLite. Rewritten to support `completed`, `failed`, and waiting-like states (`wait`/`waiting`/`delayed`/`prioritized`/`paused`), with per-state helpers (`cleanWaitingLike`, `cleanCompleted`, `cleanFailed`) that remove entries from `jobIndex`, `completedJobs`/`completedJobsData`, DLQ, `jobResults`/`jobLogs`, and SQLite (`jobs` + `dlq` tables). `'wait'` is now normalized to `'waiting'` (BullMQ alias). - **`cleanAsync()` SQLite write failures corrupted state**: `storage.deleteJob`/`deleteDlqEntry` inside cleanup loops now use swallow-and-continue wrappers so one SQLite error (e.g. `SQLITE_FULL`) does not leave the in-memory state inconsistent with disk. ### Changed - `cleanAsync('active')` is intentionally unsupported: cleaning in-flight jobs races with the worker's ack path and leaks concurrency/uniqueKey/groupId slots. Use `fail(jobId)` or `cancelJob(jobId)` to terminate an active job safely. ### Tests - 4 new regression tests in `test/client-queue-operations.test.ts` (completed cleanup, failed cleanup, `'wait'` alias, grace-period honored for completed). ## [2.7.7] - 2026-04-19 ### Fixed - **Wrong job state after server restart** (Issue #83), `getJobState`/`getJob`/`job.getState` returned `unknown`/`null` for completed, failed, and DLQ jobs after restart because `jobIndex` was not repopulated for completed/DLQ jobs during recovery. Now `getJob` and `getJobState` fall back to SQLite when `jobIndex` has no entry, correctly resolving `completed`/`failed`/`prioritized`/`delayed`/`waiting` states post-restart. `recover()` also populates `jobIndex` for restored DLQ entries. - **Stale `jobs` row retained when job enters DLQ**: `ack.ts` (MaxAttemptsExceeded), `stallDetection.ts`, `queueManager.failParent`, and `jobManagement.moveToFailed` now call `storage.deleteJob(jobId)` after `saveDlqEntry`. Without this, recovery would re-queue DLQ'd jobs as stalled actives on restart (legacy orphan rows also cleaned up via `loadDlqJobIds` guard in Phase 1 recovery). - **Write-buffer/delete race in SQLite persistence**: When a job was inserted through the 10ms-batched `writeBuffer` then immediately deleted (e.g., `removeOnComplete`), the delete ran synchronously while the insert was still pending in the buffer. On flush, the buffered insert wrote an orphan row with stale state. Added `WriteBuffer.removePending(jobId)` invoked from `deleteJob` to cancel pending inserts before SQL DELETE. - **DLQ-retried jobs did not survive restart**: `retryDlqJob`, `retryDlqJobs` (bulk), `retryDlqByFilter`, and `processAutoRetry` now re-insert the job into SQLite via `insertJob(job, true)` after pushing to the in-memory queue. Required because the jobs row is deleted when a job enters DLQ. ### Tests - New `test/issue-83-jobstate-after-restart.test.ts` (4 tests: completed-state post-restart, `jobProxy.getState` post-restart, failed/DLQ state post-restart, retryDlq-ed job persists across restart). ## [2.7.6] - 2026-04-17 ### Fixed - **Systemic silent no-op in ~20 job methods** (Issue #82 follow-up), Across 6 factories (`processor.ts`, `jobProxy.ts`, `flowJobFactory.ts`, `jobConversion.ts`, `sandboxed worker`, flow), many job methods (`retry`, `moveToWait`, `updateProgress`, `log`, `remove`, etc.) were hardcoded to no-op or silently returned stale values in TCP mode. Same class of silent corruption as the original #82 report. All wired to real handlers with explicit errors on unsupported transitions. - **`job.retry()` BullMQ contract**: Previously always routed to `retryDlq`, which silently no-op'd when the job was not in DLQ (e.g. `removeOnFail: true`, or retry attempted before DLQ persistence). Now state-dispatched: `failed→retryDlq` (throws if 0), `active→moveActiveToWait`, `waiting/prioritized/delayed→no-op`, other→throw. - **`moveToWait` semantic divergence between embedded and TCP**: Embedded called `moveActiveToWait` (active→waiting) while the TCP server handler called `promote()` (delayed→waiting). Same API, opposite outcomes. Server handler now state-dispatches to match embedded; `jobProxy` embedded path also state-dispatches. - **`Queue.obliterate()` leaked active jobs + completed state + SQLite rows**: Only shard state was cleared; `jobIndex` (processing variant), `processingShards`, `completedJobs`, `completedJobsData`, `jobResults`, `jobLogs`, `jobLocks`, `repeatChain`, `customIdMap`, DLQ, and persistence tables all survived. Pagination reported wrong counts, memory leaked, obliterated jobs could re-materialize after restart. Now fully purged. - **Sandboxed worker `ModuleNotFound` on concurrent spawn** (macOS), Two root causes: (1) `$TMPDIR` trailing slash produced `//` in wrapper path; (2) concurrent `new Worker()` raced for Bun's bundler cache. Fixed by (1) `path.join` normalization + `fsync` on write + existence poll that throws on miss, and (2) serializing the first worker spawn so the bundle is cached before siblings load. - **`res.ok` truthy read on `unknown`**: 4 sites (`extendLock` handlers, `moveToWait`) used loose `res.ok ? x : y`; harmonized to `=== true`. - **`jobProxy.extendLock` dropped the user-provided token in TCP mode**: Server saw `null` and could reject or no-op depending on `jobLocks` ownership. Token now passed through. ### Tests - New `test/obliterate-clears-completed.test.ts` (3 tests: post-complete, pagination, active-job purge). - New `test/retry-contract.test.ts` (2 tests: BullMQ contract on DLQ and non-DLQ failed jobs). - New `test/movetowait-semantics.test.ts` (3 tests: delayed, active, waiting idempotence). - New `test/audit-unwired-processor-methods.test.ts` + `test/wired-job-methods-embedded.test.ts` proving every previously-unwired method is now reachable. - Post-condition assertions added for `remove()` inside processor. ## [2.7.5] - 2026-04-16 ### Fixed - **`job.moveToFailed()` inside processor was a no-op** (Issue #82), Calling `job.moveToFailed()` inside a worker processor silently did nothing because move method callbacks were not wired to `createPublicJob`. The worker then auto-ACKed the job, marking it as completed instead of failed. Now `moveToFailed()` and `moveToCompleted()` work correctly inside processors: they send the appropriate command and prevent the auto-ACK from overriding the state. ### Changed - Extracted handler factories from `processor.ts` into new `src/client/worker/processorHandlers.ts` for single-responsibility compliance. ### Tests - 3 new issue #82 reproduction tests (`test/issue-82-moveToFailed.test.ts`) ## [2.7.4] - 2026-04-13 ### Added - **Crash recovery**: New `engine.recover()` re-enqueues orphaned executions after crash/restart. Handles three states: `running` (re-enqueue at current step), `waiting` (re-arm signal timeout or resume if signal arrived), `compensating` (re-run compensation). Returns `RecoverResult` with counts. - **Type-safe workflow steps**: `Workflow` now uses a generic accumulator pattern to track step return types at compile time. Each `.step()` narrows the return type so subsequent steps see previous results without `as` casts. Works with `.parallel()`, `.map()`, `.forEach()`, `.subWorkflow()`. Fully backward compatible. - New `src/client/workflow/compensator.ts`, Extracted `WaitForSignalError` and `runCompensation()` from executor. - New `src/client/workflow/recovery.ts`, Recovery logic with `RecoverDeps` interface and `recoverExecutions()`. - New `WorkflowStore.listRecoverable()` method, Queries SQLite for executions in recoverable states. - Exported `RecoverResult`, `TypedStepHandler`, `TypedCompensateHandler` from `bunqueue/workflow`. ### Documentation - **Workflow guide**: Added "Type-Safe Steps" and "Crash Recovery" sections, updated comparison table (+2 rows), updated Quick Start with type-safe examples, updated StepContext table, updated Limitations & Caveats, added `engine.recover()` to API table. ### Tests - 7 new crash recovery tests (`test/workflow-recovery.test.ts`) - 8 new type-safe step tests (`test/workflow-typesafe.test.ts`) ## [2.7.3] - 2026-04-12 ### Fixed - **Workflow emitter resilience**: Event listeners that throw exceptions no longer break the dispatch chain. All registered listeners are now called regardless of individual failures. - **Parallel step error aggregation**: When multiple parallel steps fail, all errors are now reported via `AggregateError` instead of silently discarding all but the first. - **forEach saga compensation**: `findStepDef()` now correctly matches indexed forEach step names (e.g. `process:0`) back to their definition, enabling proper compensation rollback for forEach iterations. - **Map node observability**: `executeMap()` now emits `step:started` and `step:completed` events, making map nodes observable like all other node types. ### Tests - Added 24 workflow engine issue reproduction tests (`test/workflow-issues.test.ts`) ## [2.7.2] - 2026-04-10 ### Added - **Loop control flow**: New `.doUntil(condition, builder, opts?)` and `.doWhile(condition, builder, opts?)` DSL methods for conditional iteration. `doUntil` runs steps then checks condition (do...until), `doWhile` checks condition first (while...do). Both support `maxIterations` safety limit (default: 100). - **forEach iteration**: New `.forEach(items, name, handler, opts?)` iterates over a dynamic item list. Results stored with indexed names (`step:0`, `step:1`, ...). Each iteration receives `ctx.steps.__item` and `ctx.steps.__index`. Supports `maxIterations` (default: 1000). - **Map transform**: New `.map(name, transformFn)` for synchronous data transforms between steps. No retry, no timeout, pure computation node. - **Schema validation**: New `inputSchema` and `outputSchema` options on `.step()`. Duck-typed `.parse()` method, works with Zod, ArkType, Valibot, or any custom schema. Input validated before handler, output validated after. - **Per-execution subscribe**: New `engine.subscribe(executionId, callback)` returns an unsubscribe function. Filters events for a specific execution only. - New `src/client/workflow/loops.ts`, Dedicated execution logic for doUntil, doWhile, forEach, and map nodes. ### Documentation - **Workflow guide**: 6 new Core Concepts sections (Loops, forEach, Map, Schema Validation, Subscribe), 5 new comparison table rows, subscribe added to API table, architecture diagram updated, 2 new real-world examples - **Blog post**: 2 new sections (Loops/forEach/Map, Schema/Subscribe), test count updated - **Examples**: 3 new examples (forEach+Map aggregation, doUntil polling, Schema+Subscribe) - **FAQ**: Feature list expanded (+5 bullets), comparison table (+3 rows), JSON-LD updated - **Homepage/Introduction/README/CLAUDE.md**: All updated with new features ### Tests - 11 new unit tests in `workflow-loops.test.ts` (doUntil, doWhile, forEach, map, subscribe, schema validation) - 6 new embedded integration tests (tests 14-19) - 6 new TCP integration tests (tests 14-19) - Fixed flaky `workflow-realistic.test.ts` (added `retry: 1` to failing step) - All 5,305 existing tests continue to pass ## [2.7.1] - 2026-04-10 ### Added - **Step retry with exponential backoff**: Steps now retry automatically with configurable `retry` count. Backoff uses `min(500ms × 2^attempt + jitter, 30s)`. Attempt count tracked in `exec.steps['name'].attempts`. - **Parallel steps**: New `.parallel()` DSL method runs multiple steps concurrently via `Promise.allSettled`. If any step fails, compensation runs for all completed steps. - **Signal timeout**: `.waitFor('event', { timeout: ms })` fails the execution if the signal doesn't arrive within the timeout, triggering compensation automatically. - **Nested workflows (sub-workflows)**: New `.subWorkflow(name, inputMapper)` composes workflows. Parent pauses while child executes; child results available in `ctx.steps['sub:']`. - **Observability (typed events)**: New `WorkflowEmitter` with 11 event types: `workflow:started/completed/failed/waiting/compensating`, `step:started/completed/failed/retry`, `signal:received/timeout`. Subscribe via `engine.on()`, `engine.onAny()`, or `onEvent` constructor option. - **Cleanup & archival**: `engine.cleanup(maxAgeMs, states?)` deletes old executions. `engine.archive(maxAgeMs, states?)` moves them to `workflow_executions_archive` table (transactional, up to 1000 per call). `engine.getArchivedCount()` returns archive size. ### Changed - Refactored `executor.ts` (362→273 lines): extracted `buildContext()`, `findStepDef()`, `executeStepWithRetry()`, `executeParallelSteps()`, `executeSubWorkflow()` to new `runner.ts` - New `emitter.ts` (115 lines) for event system - `processStep()` now allows `'waiting'` state (for signal timeout re-checks) ### Documentation - **Workflow guide**: Added 6 new sections (retry, parallel, signal timeout, nested, observability, cleanup), updated comparison table (+6 rows), API table (+7 methods), architecture diagram - **Blog post**: Added sections for retry/parallel/sub-workflows, observability, cleanup - **Examples**: Added 3 new workflow examples (parallel enrichment, nested sub-workflow, retry with observability) - **FAQ**: Updated feature list, comparison table, JSON-LD schema - **Homepage/Introduction/README**: Updated feature descriptions ### Tests - 20 new unit tests in `workflow-new-features.test.ts` (retry, parallel, signal timeout, cleanup, observability, nested workflows) - 6 new embedded integration tests (tests 8-13 in `scripts/embedded/test-workflow-engine.ts`) - 7 new TCP integration tests (tests 7-13 in `scripts/tcp/test-workflow-engine.ts`) - All 5,294 existing tests continue to pass ## [2.7.0] - 2026-04-10 ### Added - **Workflow Engine**: A new orchestration layer for multi-step business processes, built entirely on top of bunqueue's existing Queue and Worker. Zero core engine modifications, zero new infrastructure. - **Fluent DSL**: Chain `.step()`, `.branch()`, `.path()`, and `.waitFor()` to define workflows in pure TypeScript - **Saga compensation**: Attach `compensate` handlers to steps; on failure, they run automatically in reverse order, rolling back side effects (payments, reservations, database writes) - **Conditional branching**: Route execution to different paths at runtime based on step results (e.g., VIP vs standard, risk-level tiers) - **Human-in-the-loop**: `.waitFor('event')` pauses execution (persisted to SQLite); `engine.signal(id, event, payload)` resumes it, minutes, hours, or days later - **Step timeouts**: Prevent steps from running indefinitely with per-step timeout configuration - **Context passing**: Each step accesses the original input and all previous step results via `ctx.steps['step-name']` - **SQLite persistence**: Execution state is stored in a dedicated `workflow_executions` table; survives process restarts - **Embedded & TCP**: Works in both modes, just like Queue and Worker - **Import**: `import { Workflow, Engine } from 'bunqueue/workflow'` - **Export mapping**: added `"./workflow"` to package.json exports ```typescript const flow = new Workflow('order') .step('validate', async (ctx) => { ... }) .step('charge', async (ctx) => { ... }, { compensate: async () => { /* auto-rollback */ }, }) .waitFor('manager-approval') .step('ship', async (ctx) => { ... }); const engine = new Engine({ embedded: true }); engine.register(flow); const run = await engine.start('order', { orderId: 'ORD-1' }); await engine.signal(run.id, 'manager-approval', { approved: true }); ``` ### Documentation - **New page**: [Workflow Engine guide](/guide/workflow/) with competitor comparison (vs Temporal, Inngest, Trigger.dev), full API reference, and 4 production examples (e-commerce, CI/CD pipeline, KYC onboarding, ETL data pipeline) - **Quickstart**: Added Workflow Engine section with example - **README**: Added Workflow Engine section with code examples and competitor comparison table - **Sidebar**: Added Workflow Engine entry under Client SDK - **SEO**: Updated global keywords, JSON-LD structured data, and sitemap priority for workflow page ### Tests - 27 new unit tests across 3 test files (`workflow-engine`, `workflow-realistic`, `workflow-e2e-production`) - 7 new embedded integration tests (`scripts/embedded/test-workflow-engine.ts`) - 6 new TCP integration tests (`scripts/tcp/test-workflow-engine.ts`) - All 5274 existing tests continue to pass ## [2.6.116] - 2026-04-09 ### Fixed - **Deduplication broken for long-running scheduled jobs**: `cleanEmptyQueues()` was deleting unique-key entries for queues whose priority queue was empty, even when jobs holding those keys were still actively processing. This caused the dedup guard to be wiped every ~10 s (the cleanup interval), allowing `every()` / `cron()` to create duplicate jobs. The fix checks `processingShards` and `waitingDeps` before considering a queue "empty". Fixes [#80](https://github.com/egeominotti/bunqueue/issues/80). ## [2.6.115] - 2026-04-08 ### Added - **`prefixKey`, namespace isolation for `Queue` and `Worker`**: New option lets multiple environments, tenants, or services share the same broker without their jobs, workers, cron schedulers, stats, pause state, DLQ, or rate limits overlapping. `Queue.name` still reports the logical name; the prefix is applied internally to the server-side key. Backward compatible, without `prefixKey`, behavior is identical. Resolves the cron `name` PRIMARY KEY collision in [#77](https://github.com/egeominotti/bunqueue/issues/77). Example: ```typescript const dev = new Queue('emails', { prefixKey: 'dev:' }); const prod = new Queue('emails', { prefixKey: 'prod:' }); // Workers must match the prefix to consume jobs from the producing queue new Worker('emails', processor, { prefixKey: 'dev:' }); ``` See the [Namespace Isolation guide](/guide/queue/advanced/#namespace-isolation-prefixkey). ## [2.6.114] - 2026-04-07 ### Fixed - **Worker `'ready'` event never fires with chained listener**: `Worker.run()` was emitting `'ready'` synchronously inside the constructor (when `autorun: true`, the default), so listeners attached via the chained pattern `new Worker(...).on('ready', ...)` were registered too late and missed the event. The emit is now deferred via `queueMicrotask`, so listeners attached synchronously after construction still receive `'ready'`. Fixes [#76](https://github.com/egeominotti/bunqueue/issues/76). ## [2.6.113] - 2026-04-03 ### Fixed - **Cron job with `preventOverlap` fires immediately on reconnect**: Lock expiration was re-queuing cron jobs instead of discarding them, and batch ACK (`ackBatchWithResults`) silently skipped stall-retried jobs without recovery. Now cron jobs are discarded on lock expiry (the scheduler re-creates them at the next tick), and batch ACK properly recovers stall-retried jobs like single ACK does. Reported as #75. ## [2.6.112] - 2026-04-03 ### Added - **`bunqueue version` command**: Shows client version and server version (if reachable), with mismatch detection warning. - **`bunqueue doctor` command**: Run diagnostics: checks connectivity, version match, server health, queue state, and memory usage. Useful for debugging deployment issues. ## [2.6.111] - 2026-04-03 ### Fixed - **`bunqueue stats` showing zeros for waiting/active**: TCP Stats command was returning fields named `queued`/`processing` while the CLI expected `waiting`/`active`. Aligned TCP response to use standard field names (`waiting`, `active`, `failed`) consistent with HTTP `/health` endpoint. ## [2.6.110] - 2026-04-03 ### Fixed - **Stacktrace now included in `failed` worker event**: `job.stacktrace` was always `null` when a job threw an error. Now correctly populated with the error's stack trace lines, respecting `stackTraceLimit` (default: 10). Fixes [#74](https://github.com/egeominotti/bunqueue/issues/74). ## [2.6.109] - 2026-04-03 ### Changed - **Cloud instance ID required**: `BUNQUEUE_CLOUD_INSTANCE_ID` env var is now required for cloud mode (no more auto-generated UUIDs). If missing, cloud agent logs error and doesn't start; rest of bunqueue runs normally. - **Simplified cloud config**: Config file `cloud` section only exposes `url`, `apiKey`, and `instanceId`. All other cloud settings are internal (env vars only). - **Default changes**: `remoteCommands` defaults to `true` (was `false`), `includeJobData` defaults to `true` (was `false`). - **Removed `instanceId.ts`**: Deleted auto-generation/persistence of instance IDs. - **Updated docs**: Cloud section moved to end of configuration guide with beta notice. ## [2.6.108] - 2026-04-02 ### Added - **`bunqueue.config.ts`, Global configuration file**: Centralize all server configuration in a single typed file, similar to `vite.config.ts` or `drizzle.config.ts`. Auto-discovered from project root, supports `bunqueue.config.{ts,js,mjs}`. Priority: CLI flags > config file > env vars > defaults. Zero breaking changes, env vars continue to work as fallback. - **`defineConfig()` helper**: Exported from both `bunqueue` and `bunqueue/client` for full TypeScript IntelliSense. - **`--config` / `-c` CLI flag**: `bunqueue start --config ./custom.config.ts` to specify an explicit config file path. - **`CloudAgent.createFromConfig()`**: Static factory method that accepts a pre-resolved `CloudConfig`, used by the config file flow. - **New docs page**: `/guide/configuration/` with full reference, examples for development, production, and Docker/Kubernetes. - **Updated 17 docs pages**: All documentation now references `bunqueue.config.ts` as the recommended configuration approach. ## [2.6.107] - 2026-04-02 ### Fixed - **Fix contextFactory test**: updated `getLockContext` test to reflect the `storage` field added in v2.6.103 for cron job cleanup on disconnect (#73). ## [2.6.106] - 2026-04-02 ### Fixed - **Cron upsert now removes orphaned queued jobs**: between client disconnect and reconnect, a cron tick could push a job while a stale worker was still within the heartbeat timeout window. This orphaned job would sit in the queue and be pulled immediately when a new worker connected. Now, `upsertJobScheduler` with `preventOverlap` removes any existing queued job with the cron's uniqueKey before re-registering the cron, ensuring a clean slate (fixes #73, code path 6/6). ## [2.6.105] - 2026-04-02 ### Fixed - **`skipIfNoWorker` now ignores stale workers**: `getForQueue()` was returning ALL registered workers regardless of heartbeat status. When a client disconnected without clean TCP close (e.g., network issues between WSL and remote VPS), the worker remained registered as "stale" for up to 90 seconds. During this window, `skipIfNoWorker` would find the stale worker and push cron jobs. Now only workers with a recent heartbeat (within `WORKER_TIMEOUT_MS`, default 30s) are counted (fixes #73). ## [2.6.104] - 2026-04-02 ### Fixed - **Stall detector no longer re-queues cron jobs**: the stall detection system (both retry and DLQ paths) now discards cron jobs with `preventOverlap` instead of re-queuing or moving them to DLQ. This was the third code path that could cause cron jobs to fire immediately after client disconnect (fixes #73). ## [2.6.103] - 2026-04-02 ### Fixed - **Cron jobs no longer fire immediately on client reconnect**: when a TCP/WebSocket client disconnected while processing a cron job with `preventOverlap`, `releaseClientJobs` would re-queue the job as "waiting". On reconnect, the worker would pick it up immediately instead of waiting for the next scheduled time. Now, cron jobs with `preventOverlap` (uniqueKey `cron:*`) are discarded on disconnect, the cron scheduler re-creates them at the next scheduled tick (fixes #73). ## [2.6.102] - 2026-04-02 ### Fixed - **Event subscription leak on HTTP server shutdown**: `queueManager.subscribe()` returned an unsubscribe function that was discarded. On `stop()`, the subscription remained active, preventing garbage collection. Now properly unsubscribed during shutdown. ## [2.6.101] - 2026-04-02 ### Fixed - **WebSocket rate limiter leak**: WebSocket disconnect handler was not calling `removeClient()` on the rate limiter, causing per-client rate limiter state to accumulate indefinitely. TCP already did this correctly; now WebSocket matches. ## [2.6.100] - 2026-04-02 ### Fixed - **Worker deregistration on disconnect**: TCP, WebSocket, and SSE disconnect handlers now properly deregister workers when a client disconnects. Previously, workers remained registered as "active" after disconnect, causing `skipIfNoWorker` to malfunction (cron jobs would fire even with no workers connected). On reconnect, the worker would immediately pick up the queued job instead of waiting for the next scheduled time (fixes #73). - **SSE connection cleanup**: SSE `cancel` handler now releases owned jobs back to the queue on disconnect, matching the behavior of TCP and WebSocket handlers. ## [2.6.99] - 2026-04-02 ### Fixed - **Cron jobs no longer re-queue on restart**: active cron jobs with `preventOverlap` (default) are now discarded during stall recovery instead of being re-queued. Previously, if a cron job was processing when the server crashed, the recovery mechanism would re-queue it with ~1-3s backoff, causing it to fire immediately on restart. The cron scheduler now handles the next execution at the correct scheduled time (fixes #73). ## [2.6.98] - 2026-04-01 ### Fixed - **Cron overlap prevention**: added `preventOverlap` option (default: `true`) that automatically deduplicates cron-fired jobs. When a cron interval is shorter than the job processing time, the scheduler no longer pushes duplicate jobs to the queue. This prevents the "starts right away on restart" issue where accumulated jobs would fire immediately when a worker reconnects (fixes #73). ## [2.6.97] - 2026-04-01 ### Fixed - **Cron jobs no longer fire immediately on restart**: `skipMissedOnRestart` now defaults to `true`. Past-due crons recalculate `nextRun` to the next future occurrence instead of executing immediately (fixes #73). Use `skipMissedOnRestart: false` to opt in to catch-up behavior. ## [2.6.96] - 2026-04-01 ### Fixed - **Job state race condition in TCP mode**: `getJobState()` inside the `completed` event callback now correctly returns `completed` instead of `active` (fixes #72). Root cause: ACK was fire-and-forget (`void`), so the event was emitted before the server processed the acknowledgment. ## [2.6.95] - 2026-03-31 ### Added - **AI-native completeness**: three additions for perfect Claude Code integration: - `.mcp.json` at root, auto-discovery of bunqueue MCP server, no manual config needed - `agents/bunqueue-assistant.md`, specialized agent that Claude auto-delegates to for bunqueue tasks (setup, debugging, migration, optimization) - Updated `plugin.json` v1.1.0, declares all components (skills, agents, MCP), adds keywords for discoverability ## [2.6.94] - 2026-03-31 ### Added - **Claude Code plugin & skills**: AI-native integration for bunqueue (closes #71): - `.claude-plugin/plugin.json`, distributable plugin manifest, installable via `/plugin marketplace add egeominotti/bunqueue` - `skills/bunqueue/SKILL.md`, public skill with Simple Mode (all 12 features), Queue+Worker, auto-batching, QueueGroup, webhooks, S3 backup, MCP server, BullMQ migration guide - `skills/bunqueue/reference.md`, full API reference (Queue, Worker, Bunqueue, FlowProducer, QueueGroup, all options) - `skills/bunqueue/examples.md`, 10 real-world patterns (email service, API gateway, ETL pipeline, webhook processor, image processing, batch DB, multi-queue, cron reports, distributed TCP, search debounce, OTP with TTL) + BullMQ migration checklist - `skills/bunqueue/mcp.md`, MCP server documentation (73 tools, 5 resources, 3 diagnostic prompts, setup for embedded & TCP) - `.claude/skills/bunqueue-dev/SKILL.md`, internal contributor skill (architecture, conventions, testing workflow) ## [2.6.93] - 2026-03-31 ### Fixed - **Deduplication bypass while job is active**: `handleDeduplication` now checks `jobIndex` for active/processing jobs, not just the priority queue. Previously, pushing a job with the same `uniqueKey` while the original was still being processed would create a duplicate. Also fixed `pushJob` fall-through when dedup returned `skip: true` but the job wasn't in the queue (active). Fixes #69. ## [2.6.92] - 2026-03-31 ### Added - **Simple Mode: 4 new production features** (zero core modifications): - **Job Deduplication**: auto-dedup by name+data with configurable TTL, extend, replace modes - **Job Debouncing**: coalesce rapid same-name jobs within a TTL window - **Rate Limiting**: `rateLimit` option (max/duration/groupKey) + runtime `setGlobalRateLimit()` - **DLQ Auto-Management**: `dlq` option for auto-retry, max age, max entries; full DLQ API (getDlq, getDlqStats, retryDlq, purgeDlq) - 9 new unit tests for the 4 features ## [2.6.91] - 2026-03-31 ### Added - **Simple Mode: 8 advanced features**: all built on top of existing Queue/Worker APIs with zero core modifications: - **Batch Processing**: accumulate N jobs, flush on size or timeout, per-job Promise resolution - **Advanced Retry**: 5 strategies (fixed, exponential, jitter, fibonacci, custom), `retryIf` predicate - **Graceful Cancellation**: AbortController per job, `cancel()`, `isCancelled()`, `getSignal()` - **Circuit Breaker**: auto-pause worker after N consecutive failures, half-open recovery - **Event Triggers**: declarative "on job A complete → create job B" with optional conditions - **Job TTL**: expire unprocessed jobs, per-name overrides, runtime updates - **Priority Aging**: automatically boost priority of old waiting/prioritized jobs - **Modular architecture**: each feature in its own file under `src/client/bunqueue/` (max 300 lines each) - **50 unit tests** for Simple Mode features, 29 integration assertions - **Comprehensive documentation**: super detailed guide with architecture diagrams, code examples, and interaction notes ## [2.6.90] - 2026-03-31 ### Added - **Simple Mode (`Bunqueue` class)**: new unified API that combines Queue + Worker into a single object. Includes route-based job dispatching, onion-model middleware chain, and simplified cron scheduling via `cron()` and `every()`. Works in both embedded and TCP modes. Import as `import { Bunqueue } from 'bunqueue/client'`. - **Documentation**: comprehensive Simple Mode guide at `/guide/simple-mode/`, README section, and CLAUDE.md reference. ## [2.6.89] - 2026-03-30 ### Fixed - **`getPrioritized()` returning empty array**: `end=-1` (default) was not normalized in the embedded path of `getJobsAsync`, causing `maxPerSource=0` and zero results. Now handles `end=-1` consistently with the TCP path. ## [2.6.88] - 2026-03-30 ### Fixed - **ESLint crash on `flow.ts`**: removed unnecessary explicit `` type arguments from `createFlowJobObject` calls that caused `@typescript-eslint/no-unnecessary-type-arguments` rule to crash during `bun run lint`. ## [2.6.87] - 2026-03-30 ### Fixed - **`skipIfNoWorker` not working on restart** ([#67](https://github.com/egeominotti/bunqueue/issues/67)), when a cron job had `skipIfNoWorker: true` and the server restarted with past-due `nextRun`, the missed cron fired immediately because workers reconnected before the scheduler tick. The `load()` method now recalculates `nextRun` to the next future occurrence when `skipIfNoWorker` is enabled, preventing missed cron executions on restart. ## [2.6.85] - 2026-03-26 ### Added - **`skipIfNoWorker`** option for cron jobs ([#65](https://github.com/egeominotti/bunqueue/issues/65)), when enabled, the cron scheduler skips job creation if no workers are registered for the target queue. Prevents job accumulation when clients go offline while the server keeps running. Works in both embedded and TCP modes. - Schema migration v9: `skip_if_no_worker` column on `cron_jobs` table ## [2.6.84] - 2026-03-26 ### Fixed - **`immediately: true` conflicting with `skipMissedOnRestart`** ([#65](https://github.com/egeominotti/bunqueue/issues/65)): - `immediately` now only fires on **first creation**, not on subsequent upserts - Previously, every call to `upsertJobScheduler` with `immediately: true` would override `skipMissedOnRestart` and fire the cron immediately, even after a server restart - This was the root cause of the TCP-mode report: the user's app called `upsertJobScheduler` on every startup with both flags, causing the cron to fire immediately despite `skipMissedOnRestart` ## [2.6.83] - 2026-03-26 ### Fixed - **`immediately: true` now works in TCP mode** ([#65](https://github.com/egeominotti/bunqueue/issues/65)): - Added `immediately` field to TCP `Cron` command type - Wired `immediately` through TCP handler (`handleCron`) and client TCP path (`upsertJobScheduler`) - Full TCP parity: `immediately`, `skipMissedOnRestart` now work identically in both embedded and TCP modes ## [2.6.82] - 2026-03-26 ### Fixed - **`skipMissedOnRestart` not working via `Queue#upsertJobScheduler`** ([#65](https://github.com/egeominotti/bunqueue/issues/65)): - `CronScheduler.add()` now preserves existing `executions` count when upserting a cron (previously reset to 0 on every call) - `CronScheduler.load()` now persists recalculated `nextRun` to the database when `skipMissedOnRestart` adjusts it - `immediately: true` option is now supported in `CronJobInput`, fires the cron immediately on creation, then continues on schedule - Wired `immediately` through `upsertJobScheduler` embedded path - **Embedded `test-cron-event-driven` test hanging**: added `shutdownManager()` call to properly clean up the shared QueueManager singleton and its background task timers ## [2.6.81] - 2026-03-26 ### Added - **Worker API enhancements** (BullMQ v5 compatibility): - `concurrency` getter/setter, change concurrency at runtime without restarting the worker - `closing` property, Promise that resolves when `close()` finishes - `off()` typed overloads, remove event listeners with full TypeScript support - `name` and `opts` are now public readonly properties - **Worker options now fully wired**: - `skipLockRenewal`, disables heartbeat timer when `true` - `skipStalledCheck`, disables stalled event subscription when `true` - `drainDelay`, configurable delay between polls when queue is drained (default: 50ms, was hardcoded) - `lockDuration`, stored in opts with default 30000ms - `maxStalledCount`, stored in opts with default 1 - `removeOnComplete` / `removeOnFail`, worker-level defaults applied to all processed jobs ### Fixed - `drainDelay` default corrected from 5000ms to 50ms in documentation ### Removed - Cleaned up 7 unimplemented WorkerOptions stubs that were type-only (now all options are wired to actual behavior) ## [2.6.80] - 2026-03-25 ### Fixed - **Issue #64 follow-up**: Jobs no longer lost from in-memory queue when `markActive()` fails during pull. Previously, if SQLite threw a disk I/O error during `moveToProcessing()`, the job was already popped from the priority queue but never delivered to the worker, silently stuck in "waiting" state forever. `markActive()` is now non-fatal (persistence failure doesn't block processing), and a safety-net `requeueJob()` restores jobs to the queue if `moveToProcessing()` fails for any reason ## [2.6.79] - 2026-03-25 ### Fixed - **Issue #63 follow-up**: `getStallConfig()` and `getDlqConfig()` in TCP mode now return the correct config after calling `setStallConfig()`/`setDlqConfig()` instead of always returning hardcoded defaults. Added client-side cache so sync getters reflect the last-set values immediately ## [2.6.78] - 2026-03-25 ### Fixed - **Issue #61**: `JobTemplate` is now generic `JobTemplate`, `data` field correctly inherits the Queue's type parameter instead of being `unknown`. Fixed incorrect docs in `use-cases` showing `data` in the second parameter instead of the third. Exported `RepeatOpts`, `JobTemplate`, `SchedulerInfo` types from `bunqueue/client` - **Issue #63**: Cloud dashboard `queue:detail` response now includes `enabled` field in `stallConfig`, allowing the dashboard to properly display and toggle stall detection - **Issue #64**: Added WAL checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) before `db.close()` to prevent stale locks and `disk I/O error` on rapid restarts in embedded mode ### Added - **`skipMissedOnRestart`** option for cron jobs, when enabled, cron jobs that were missed during server downtime are skipped and rescheduled to the next future run instead of being executed immediately on restart. Default: `false` (preserves existing catch-up behavior) - Schema migration v8: `skip_missed_on_restart` column on `cron_jobs` table ## [2.6.77] - 2026-03-24 ### Fixed - `removeChildDependency()` TCP response now returns `{ ok: true, removed: boolean }` separately; client reads `res.removed` instead of `res.ok` to correctly reflect whether the dependency was actually removed ## [2.6.76] - 2026-03-24 ### Added - Integration test scripts for monitoring, query operations, cron event-driven scheduling, and sandboxed workers (TCP + embedded modes) - Unit tests for issues #29 (sandboxed worker `log` method), #38 (sandboxed processor cleanup), #41 (sandboxed idle RAM) ## [2.6.75] - 2026-03-24 ### Added - **`removeDependencyOnFailure`**: When a child job terminally fails with this option set, it is silently removed from the parent's pending dependencies. If it was the last pending child, the parent is promoted to the waiting queue and processed normally. - **`ignoreDependencyOnFailure`**: Same as `removeDependencyOnFailure` but also stores the failure reason so the parent worker can retrieve it via `job.getIgnoredChildrenFailures()`. - **`continueParentOnFailure`**: When a child job with this option fails, the parent is immediately promoted to the waiting queue (even if other children are still pending). The parent worker can then call `job.getFailedChildrenValues()` to inspect which children failed and why, and `job.removeUnprocessedChildren()` to cancel remaining unstarted children. - **`job.getFailedChildrenValues()`**: Returns `Record` mapping child keys (`"queue:jobId"`) to their error messages. Populated by `continueParentOnFailure` child failures. - **`job.getIgnoredChildrenFailures()`**: Returns `Record` of failure reasons for children that failed with `ignoreDependencyOnFailure`. - **`job.removeChildDependency()`**: Removes a child job's pending dependency from its parent. If this was the last pending child, the parent is promoted to the queue. Throws if the job has no parent. - **`job.removeUnprocessedChildren()`**: Cancels all unprocessed (waiting/delayed) children of a parent job. Active, completed, and failed children are unaffected. - TCP commands for new methods: `GetFailedChildrenValues`, `GetIgnoredChildrenFailures`, `RemoveChildDependency`, `RemoveUnprocessedChildren`. - All four new options are fully propagated through `FlowProducer.add()`, `FlowProducer.addBulk()`, and the TCP `PUSH` command. ## [2.6.74] - 2026-03-23 ### Changed - **Cloud: dynamic ingest interval**: Snapshot interval now adapts automatically to payload size: < 50KB → 5s, 50–200KB → 10s, 200–500KB → 20s, > 500KB → 30s. Previously fixed at 15s regardless of load. - **Cloud: unbounded job collection**: Removed the 10k total cap on `recentJobs[]`. Each state is now collected in full, bounded only by in-memory eviction limits (50k completed FIFO, etc). - **Cloud: removed `/batch` ingest endpoint**: Recovery now resends buffered snapshots one-by-one to the standard `/api/v1/ingest` endpoint, simplifying the protocol. ## [2.6.73] - 2026-03-23 ### Added - **Job timeline tracking**: Every job now records a `timeline: JobTimelineEntry[]` array that tracks all state transitions (`waiting`, `active`, `completed`, `failed`, `delayed`, `prioritized`, `waiting-children`) with timestamps, error messages, and attempt numbers. Max 20 entries per job. - **Timeline SQLite persistence**: Job timeline is persisted as a msgpack BLOB column in SQLite (schema v7 migration). Timeline survives server restarts and is available for DB-loaded jobs. - **Cloud snapshot: timeline field**: `recentJobs[]` in cloud snapshots now includes `timeline` when present, giving the dashboard exact state-transition history for each job. - **Cloud snapshot: failed job duration enrichment**: Failed jobs in `recentJobs[]` are now enriched with `duration`, `completedAt`, and `totalDuration` from DLQ attempt history, since `completedAt` is null for failed jobs. ## [2.6.72] - 2026-03-23 ### Added - **Cloud snapshot: `waiting-children` state**: Jobs in `waiting-children` state are now collected in `recentJobs[]` and counted in both global `stats` and per-queue `queues[]`. Dashboard can now display parent jobs waiting for children. - **Cloud snapshot: `prioritized` state in job collection**: `recentJobs[]` now includes jobs with `state: 'prioritized'`. Previously only `waiting/active/delayed/failed/completed` were collected. - **Cloud snapshot: worker computed fields**: `workerDetails[]` now includes `uptime` (ms since registration), `status` (`'active'|'idle'|'stalled'`), `errorRate` (0-1), and `utilization` (activeJobs/concurrency). - **Cloud snapshot: `queueExtended`**: Per-queue extended telemetry: `uniqueKeys` (active dedup keys), `activeGroups` (FIFO groups), `waitingDeps` (jobs awaiting dependencies), `waitingChildren` (parents awaiting children). - **Cloud snapshot: `eventSubscribers`**: Count of active event subscribers (SSE, WebSocket, internal). - **Cloud snapshot: `pendingDepChecks`**: Number of dependency checks awaiting flush. - **TCP `GetJobCounts`: `waiting-children`**: TCP protocol now returns `waiting-children` count in job counts response. ### Fixed - **`getJobs()` with `state: 'waiting-children'`**: SQLite and in-memory query paths now correctly return jobs in `waitingDeps`/`waitingChildren` maps when filtering by `waiting-children` state. ## [2.6.71] - 2026-03-23 ### Added - **BullMQ v5 `prioritized` state**: Jobs with `priority > 0` now report state `'prioritized'` instead of `'waiting'`, matching BullMQ v5 exactly. Affects `getJobState()`, `getJobCounts()`, Prometheus metrics, cloud snapshot, SSE/WebSocket events, and MCP adapter. - **BullMQ v5 `waiting-children` state**: Parent jobs in flows correctly report `'waiting-children'` state while waiting for child jobs to complete. - **`failParentOnFailure`**: When a child job terminally fails with `failParentOnFailure: true`, the parent job is automatically moved to `failed` state. Handles race conditions where child fails before parent linkage is established. - **Flow atomicity**: `FlowProducer.add()` and `addBulk()` now automatically roll back all created jobs if any part of the flow fails during creation. - **`FlowOpts` with `queuesOptions`**: Pass per-queue default job options as second argument to `flow.add(flowJob, { queuesOptions: { queueName: { attempts: 5 } } })`. - **FlowProducer extends EventEmitter**: BullMQ v5 compatible. `close()` returns `Promise`, `closing` property tracks shutdown, `disconnect()` alias. - **Job move operations**: `moveActiveToWait`, `changeWaitingDelay`, `moveToWaitingChildren` state transitions with proper resource cleanup (concurrency slots, unique keys, group locks). ### Fixed - **TOCTOU in `moveParentToFailed`**: Re-checks `jobIndex` inside write lock to prevent duplicate DLQ entries when multiple children with `failParentOnFailure` fail concurrently. - **Unhandled promise rejections**: `moveParentToFailed` calls now have `.catch()` handlers instead of fire-and-forget `void`. - **SQLite `queryJobs(state='prioritized')`**: Translates `'prioritized'` to `WHERE state='waiting' AND priority > 0` since SQLite never stores 'prioritized' as a state value. - **`moveActiveToWait` resource leak**: Now calls `releaseJobResources()` to free concurrency/uniqueKey/group slots before re-queueing. - **Move operations handle `prioritized` state**: `moveJobToWait` and `moveJobToDelayed` now correctly handle jobs in `'prioritized'` state. - **Cloud snapshot**: Added `prioritized` to stats and per-queue data. Per-queue data now uses `failed` instead of `dlq` (BullMQ v5 compatible). ### Changed - **Documentation**: Updated state machine diagrams, API types, FlowProducer guide, migration guide with BullMQ v5 parity tables, cloud contract with new snapshot fields. ## [2.6.67] - 2026-03-22 ### Changed - **Disabled flaky SandboxedWorker tests**: Commented out all 35 SandboxedWorker tests across 5 files. Bun's Worker threads are still unstable and cause intermittent race conditions and crashes in parallel test runs. Tests will be re-enabled once Bun Workers stabilize. ## [2.6.66] - 2026-03-22 ### Fixed - **Deduplication not working for JobScheduler (Issue #60)**: `upsertJobScheduler` accepted deduplication options in the `JobTemplate` but silently discarded them. The cron system (`CronJob`, `CronJobInput`, `cronScheduler`) had no fields for `uniqueKey` or `dedup`, so every cron tick created a new job regardless of deduplication settings. Now dedup options are stored in the cron job (including SQLite persistence with schema migration v6) and passed through to `pushJob()` on each tick. When a worker is slow or offline, only one job per dedup key exists instead of unbounded duplicates. ## [2.6.65] - 2026-03-22 ### Added - **MCP operation tracking for Cloud dashboard**: Every MCP tool invocation (73 tools) is now tracked and sent to bunqueue.io as part of the cloud snapshot. Each operation records: tool name, queue affected, timestamp, duration, success/failure, and error message. Data is buffered in a bounded ring buffer (max 200 ops, ~40KB) and drained into each snapshot. In embedded mode, the MCP process creates its own CloudAgent to send telemetry. Zero overhead when cloud is not configured. Includes `mcpOperations` (raw invocation history) and `mcpSummary` (aggregated stats with top tools) fields in `CloudSnapshot`. ## [2.6.64] - 2026-03-21 ### Fixed - **No-lock ack fails after stall re-queue (data loss)**: When a worker with `useLocks=false` processed a job that stall detection re-queued, the `ack()` call threw "Job not found" with no recovery path, leaving the job stuck in the queue forever. The existing Issue #33 handler (`completeStallRetriedJob`) only fired when a lock token was present. Now the handler also fires for tokenless acks when the job was stall-retried (`attempts > 0`), preventing false completions of freshly-pushed jobs. ## [2.6.63] - 2026-03-21 ### Performance - **WorkerRateLimiter: O(n) → O(1) amortized**: Replaced `Array.filter()` with head-pointer eviction for sliding window token expiration. Eliminates per-poll array allocation and removes `Math.min(...spread)` (potential stack overflow on large token arrays). Benchmarked: 10k tokens went from 31µs to ~0µs per call; zero memory allocation per poll cycle. - **FlowProducer: parallel sibling creation in TCP mode**: `add()`, `addBulk()`, `addBulkThen()`, and `addTree()` now create independent children/jobs concurrently via `Promise.all`. TCP benchmark shows **3–6x speedup** for flows with 10–20 children (network round-trips overlap instead of serializing). `addBulkThen()` uses `Promise.allSettled` for proper cleanup on partial failure. No impact in embedded mode (pushes are synchronous). `addChain()` unchanged (sequential by design). ## [2.6.62] - 2026-03-21 ### Fixed - **E2E webhook tests failing after SSRF validation**: Added `validateWebhookUrls` option to `QueueManagerConfig` so tests using localhost can disable URL validation. ## [2.6.60] - 2026-03-21 ### Fixed - **Webhook SSRF prevention in embedded mode**: `WebhookManager.add()` now validates URLs against SSRF (localhost, private IPs, cloud metadata). Previously only enforced at TCP server layer, leaving embedded SDK unprotected. - **Docs: pin Zod v3 for Starlight**: Fixed Vercel build crash caused by Zod v4 incompatibility with Starlight 0.31. ### Changed - **Extracted `validateWebhookUrl` to shared module**: `src/shared/webhookValidation.ts` is now the single source of truth, re-exported from `protocol.ts` for backward compatibility. ## [2.6.49] - 2026-03-20 ### Added - **Cloud: 20 new remote commands**: Full dashboard control via WebSocket: - Queue: `obliterate`, `promoteAll`, `retryCompleted`, `rateLimit`, `clearRateLimit`, `concurrency`, `clearConcurrency`, `stallConfig`, `dlqConfig` - Job: `push`, `priority`, `discard`, `delay`, `updateData`, `clearLogs` - Webhook: `add`, `remove`, `set-enabled` - Other: `s3:backup` - **Shared `deriveState` and `mapJob` helpers**: Eliminated triplicated state derivation logic in command handlers. ## [2.6.48] - 2026-03-20 ### Changed - **Cloud: auth via HTTP upgrade headers**: WebSocket authentication now uses `Authorization`, `X-Instance-Id`, and `X-Remote-Commands` headers on the upgrade request (Bun-specific). Eliminates the JSON handshake message and the 100ms delay workaround. - **Cloud: removed client-side ping**: Client-side ping (every 10s) was causing false disconnects (code 4000). Keepalive now relies solely on server-side ping (25s) with bunqueue responding pong. ### Fixed - **Cloud: duplicate reconnect guard**: `scheduleReconnect()` now prevents multiple concurrent reconnect timers. - **Cloud: `onclose` logs at `info` level**: Previously `debug`, making reconnect failures invisible in production logs. ## [2.6.47] - 2026-03-20 ### Added - **Programmatic `dataPath` for embedded mode**: Queue and Worker accept `dataPath` option to set the SQLite database path without env vars. Resolves conflicts with apps that use their own `DATA_PATH`. ([#59](https://github.com/egeominotti/bunqueue/issues/59)) - **`BUNQUEUE_DATA_PATH` / `BQ_DATA_PATH` env vars**: New namespaced env vars for data path configuration. Priority: `BUNQUEUE_DATA_PATH` > `BQ_DATA_PATH` > `DATA_PATH` > `SQLITE_PATH`. Backward compatible. - **Cloud: snapshots via WebSocket**: Snapshots are now sent over WS when connected (`{ type: "snapshot", ...data }`), falling back to HTTP POST only when WS is down. ## [2.6.46] - 2026-03-20 ### Added - **Cloud: resilient WebSocket with ring buffer**: Events are buffered (max 1000) when WS is disconnected and flushed after `handshake_ack` on reconnect (with 5s fallback timeout). Zero event loss during brief disconnections. - **Cloud: client-side ping heartbeat**: bunqueue sends `{ type: "ping" }` every 10s to the dashboard; if no pong within 5s, closes socket and reconnects. Dead connection detection reduced from ~40s to ~10s. - **Cloud: dual-channel failover**: When WS is down, buffered events are embedded in the HTTP snapshot (`snapshot.events`), so the dashboard stays informed even during prolonged disconnections. ### Fixed - **Cloud: double reconnect race**: Pong timeout no longer calls `scheduleReconnect()` directly; delegates to `onclose` to prevent duplicate sockets. - **Cloud: local socket reference**: All handlers (pong, handshake, commands) use the local `ws` variable, not `this.ws`, preventing replies on stale sockets after reconnect. - **Cloud: old socket cleanup**: Previous socket is explicitly closed and handlers nulled before creating a new connection. ## [2.6.45] - 2026-03-20 ### Added - **Cloud: `prev` and `delay` fields in WebSocket events**: CloudEvent now forwards all JobEvent fields: `prev` (previous state on removed/retried) and `delay` (ms for delayed jobs). ### Fixed - **Cloud: WebSocket binary frame handling**: Ping/pong and command messages now handle both text and binary WebSocket frames (ArrayBuffer/Buffer), preventing silent parse failures behind Cloudflare. ## [2.6.44] - 2026-03-20 ### Fixed - **Cloud: WebSocket ping/pong heartbeat**: Pong responses are now sent regardless of `BUNQUEUE_CLOUD_REMOTE_COMMANDS` config. Previously, ping messages were silently dropped when remote commands were disabled, causing the dashboard to disconnect the agent every ~60s as a zombie connection. ## [2.6.43] - 2026-03-19 ### Added - **Cloud: `job:list` command**: Paginated job listing per queue with state filtering (`queue`, `state`, `limit`, `offset`). - **Cloud: `job:get` command**: Full job detail with logs and result included. - **Cloud: `queue:detail` command**: Queue detail with counts, config, DLQ entries, and job list. ### Fixed - **Cloud: recentJobs now includes completed/failed jobs**: Was only querying waiting/active/delayed states. - **Cloud: `job:list` total count**: Now returns actual queue count instead of page length. - **Cloud: activeQueues filter**: Restored skip-empty-queues optimization that was broken by over-broad filter. ## [2.6.42] - 2026-03-19 ### Performance - **Cloud: two-tier snapshot collection**: Light data (stats, throughput, latency, memory) collected every 5s at O(SHARD_COUNT). Heavy data (recentJobs, dlqEntries, topErrors, workerDetails, queueConfigs, webhooks) collected every 30s and cached between refreshes. Heavy collectors skip empty queues (only iterate queues with waiting/active/dlq > 0). Eliminated double `getQueueJobCounts()` pass. ### Fixed - **Cloud: totalCompleted/totalFailed per queue**: Was sending in-memory BoundedSet count (resets when full). Now sends cumulative counters from `perQueueMetrics` (never resets). ## [2.6.41] - 2026-03-19 ### Enhanced - **bunqueue Cloud: enterprise-grade telemetry**: Snapshot now includes per-queue totals (`totalCompleted`/`totalFailed`), connection stats (TCP/WS/SSE clients), webhook delivery stats, top errors grouped by message, cron execution counts, S3 backup status, rate limit and concurrency config per queue. Added `job:logs` and `job:result` remote commands for on-demand data. Auth errors (401/403) now logged at error level instead of silently buffered. ## [2.6.40] - 2026-03-19 ### Added (Beta) - **bunqueue Cloud**: Remote dashboard telemetry agent. Connect any bunqueue instance to bunqueue Cloud with just 2 env vars (`BUNQUEUE_CLOUD_URL` + `BUNQUEUE_CLOUD_API_KEY`). Zero overhead when disabled. - **Snapshot channel**: HTTP POST every 5s with full server state: stats, throughput, latency percentiles, memory, per-queue counts, worker details, cron jobs, storage status, DLQ entries, recent jobs. - **Event channel**: Outbound WebSocket for real-time job event forwarding (Failed, Stalled, etc.) with configurable filtering. - **Remote commands (opt-in)**: Dashboard can execute commands on the instance via the same WebSocket: `queue:pause`, `queue:resume`, `queue:drain`, `dlq:retry`, `dlq:purge`, `job:cancel`, `job:promote`, `cron:upsert`, `cron:delete`. Requires `BUNQUEUE_CLOUD_REMOTE_COMMANDS=true`. - **Multi-instance**: Multiple bunqueue instances can connect to the same dashboard with separate instance IDs and names. - **Resilience**: Offline snapshot buffer (720 snapshots), circuit breaker, WebSocket auto-reconnect with exponential backoff + jitter, graceful shutdown with final snapshot. - **Security**: API key auth, optional HMAC-SHA256 signing, job data redaction, remote commands disabled by default. - **New env vars**: `BUNQUEUE_CLOUD_URL`, `BUNQUEUE_CLOUD_API_KEY`, `BUNQUEUE_CLOUD_INSTANCE_NAME`, `BUNQUEUE_CLOUD_INTERVAL_MS`, `BUNQUEUE_CLOUD_REMOTE_COMMANDS`, `BUNQUEUE_CLOUD_SIGNING_SECRET`, `BUNQUEUE_CLOUD_INCLUDE_JOB_DATA`, `BUNQUEUE_CLOUD_REDACT_FIELDS`, `BUNQUEUE_CLOUD_EVENTS`. ## [2.6.39] - 2026-03-18 ### Fixed - **`EventType.Paused` / `EventType.Resumed` missing from enum**: Added `Paused` and `Resumed` variants to `EventType` const enum, fixing TypeScript compilation errors in `queueManager.ts` and `client/events.ts`. - **`UnrecoverableError` / `DelayedError` not exported**: Added `src/client/errors.ts` with BullMQ-compatible error classes (`UnrecoverableError` to skip retries, `DelayedError` to re-delay jobs) and exported them from `bunqueue/client`. - **Webhook mapping for pause/resume events**: `eventsManager.ts` now handles `Paused` and `Resumed` event types in the webhook switch. ### Added - **Issue #53 test**: Regression test for worker `log` event firing. ## [2.6.38] - 2026-03-18 ### Added - **Worker registration + heartbeat system**: Worker SDK now auto-registers with the server on `run()`, sends periodic heartbeats with `activeJobs`/`processed`/`failed` stats, and unregisters on `close()`. The server tracks `hostname`, `pid`, `uptime` per worker. `GET /workers` and `ListWorkers` TCP command return full worker details including aggregate stats. Dashboard receives real-time events (`worker:connected`, `worker:heartbeat`, `worker:disconnected`). - **`RegisterWorkerCommand` extended**: Accepts `workerId`, `hostname`, `pid`, `startedAt` from client. Re-registration with same `workerId` updates instead of duplicating. - **`HeartbeatCommand` extended**: Accepts `activeJobs`, `processed`, `failed` to sync client-side stats to server. - **`onOutcome` callback in processor**: Tracks completed/failed counts without adding event listeners. ### Removed - Flaky embedded tests (sandboxed-workers, cron-event-driven, query-operations) ## [2.6.37] - 2026-03-17 ### Added - **`getJobCounts` now returns `delayed` and `paused` counts**: Matches BullMQ's `getJobCounts()` return type. Both embedded and TCP modes include `delayed` (jobs with future `runAt`) and `paused` (waiting jobs count when queue is paused). ([#56](https://github.com/egeominotti/bunqueue/issues/56)) - **`getJobs` supports multiple statuses**: Accepts `string | string[]` for the `state` parameter, matching BullMQ's `getJobs(types?: JobType | JobType[])` interface. Works in embedded, TCP, and HTTP (`?state=waiting&state=delayed`). ([#55](https://github.com/egeominotti/bunqueue/issues/55)) - **`GET /queues/summary` endpoint**: Returns all queues with name, paused status, and job counts in a single HTTP call, replacing N+1 round-trips. ### Removed - Flaky TCP integration tests (sandboxed-worker, monitoring) ## [2.6.36] - 2026-03-17 ### Fixed - **`/queues/:queue/jobs/list` performance**: Endpoint was taking 300-450ms even with `limit=2` because it scanned the entire jobIndex (O(N) iterations + O(N) individual SQLite lookups) then sorted all results. Now delegates to a single indexed SQLite query with `LIMIT/OFFSET`, reducing response time to <5ms. ## [2.6.35] - 2026-03-16 ### Changed - Removed flaky SandboxedWorker flow failure test ## [2.6.34] - 2026-03-16 ### Fixed - **QueueEvents failed events**: `failedReason` now correctly reads from `event.error` instead of `event.data`, job `data` is included in failed broadcasts, and error emission includes event context. ([#54](https://github.com/egeominotti/bunqueue/pull/54)), thanks @simontong ### Changed - **CI**: Disabled TCP and Embedded integration tests in GitHub Actions pipeline - Removed flaky SandboxedWorker tests ## [2.6.33] - 2026-03-16 ### Fixed - **Worker `log` event**: `worker.on('log', (job, message) => ...)` now works with full TypeScript autocomplete. The `log` event is emitted when `job.log()` is called inside the processor, matching SandboxedWorker behavior. ([#53](https://github.com/egeominotti/bunqueue/issues/53)) ## [2.6.32] - 2026-03-16 ### Added - **13 new WebSocket/SSE events**: `job:expired`, `flow:completed`, `flow:failed`, `queue:idle`, `queue:threshold`, `worker:overloaded`, `worker:error`, `cron:skipped`, `storage:size-warning`, `server:memory-warning` (+ `flow:*` wildcard). Total event types: 86. - **Monitoring checks**: Periodic threshold monitoring runs on cleanup interval (10s). Configurable via env vars: `QUEUE_IDLE_THRESHOLD_MS`, `QUEUE_SIZE_THRESHOLD`, `MEMORY_WARNING_MB`, `STORAGE_WARNING_MB`, `WORKER_OVERLOAD_THRESHOLD_MS`. - **Cron overlap detection**: Crons skip execution if the previous instance fired within 80% of the repeat interval, emitting `cron:skipped` instead. - **Flow lifecycle events**: `flow:completed` when all children of a parent job finish, `flow:failed` when a child permanently fails (moves to DLQ). ### Changed - **SandboxedWorker docs**: Clearly marked as experimental across all documentation pages (worker, migration, CPU-intensive, stall-detection, troubleshooting). Production recommendation to use standard `Worker` instead. ## [2.6.31] - 2026-03-16 ### Added - **SandboxedWorker `autoStart` option**: Automatically restart the worker pool when new jobs arrive after idle shutdown. Set `autoStart: true` with `idleTimeout` to get workers that sleep when idle and wake up when needed. Configurable poll interval via `autoStartPollMs` (default: 5000ms). Closes #51. ## [2.6.30] - 2026-03-16 ### Added - **Full WebSocket/SSE event coverage**: 73 unique event types now emitted across all transports. Every state change, operation, and lifecycle event is observable via WebSocket pub/sub and SSE. - **New event categories**: `job:timeout`, `job:lock-expired`, `job:deduplicated`, `job:waiting-children`, `job:dependencies-resolved`, `job:stalled` (dashboard), `job:moved-to-delayed` - **Backup events**: `storage:backup-started`, `storage:backup-completed`, `storage:backup-failed` - **Connection tracking**: `client:connected`, `client:disconnected`, `auth:failed` - **Batch events**: `batch:pushed`, `batch:pulled` - **DLQ maintenance events**: `dlq:auto-retried`, `dlq:expired` - **Cron lifecycle**: `cron:fired`, `cron:missed`, `cron:updated` (distinguish create vs update) - **Worker events**: `worker:heartbeat`, `worker:idle`, `worker:removed-stale` - **Webhook events**: `webhook:fired`, `webhook:failed`, `webhook:enabled`, `webhook:disabled` - **Queue lifecycle**: `queue:created`, `queue:removed` (on obliterate and cleanup) - **Rate/concurrency**: `ratelimit:hit`, `ratelimit:rejected`, `concurrency:rejected` - **Server lifecycle**: `server:started`, `server:shutdown`, `server:recovered` - **Cleanup events**: `cleanup:orphans-removed`, `cleanup:stale-deps-removed` - **Memory**: `memory:compacted` ## [2.6.29] - 2026-03-16 ### Added - **TCP integration tests**: 4 new test suites: backoff strategies, job move methods, parent failure options, worker advanced methods. TCP test coverage now at 56 suites. ## [2.6.28] - 2026-03-15 ### Fixed - **`getChildrenValues` empty in TCP mode**: Fixed response envelope unwrap in worker processor (`response.data.values` instead of `response.values`). Fixed `childrenIds`/`parentId` not passed through TCP protocol in flow jobs. (#49, PR by @simontong) ## [2.6.27] - 2026-03-15 ### Fixed - **`getJob` returns null for failed/DLQ jobs**: In embedded mode (no SQLite storage), `getJob()` and `getJobByCustomId()` now correctly query the shard DLQ instead of returning null. (#50) - **`getChildrenValues` wired in worker**: Worker job processor now correctly passes the `getChildrenValues` callback. ### Added - **WebSocket/SSE integration tests**: 88 new integration tests covering WebSocket and SSE event streaming. ## [2.6.26] - 2026-03-15 ### Added - **Enterprise-grade SSE**: Event IDs for client-side deduplication, Last-Event-ID resume with ring buffer (1000 events), heartbeat keepalive (30s), retry field (3s auto-reconnect), connection limit (1000 max with 503 rejection). - **Enterprise-grade WebSocket**: Backpressure detection via getBufferedAmount() (1MB threshold), dead client cleanup in emit/broadcast, connection limit (1000 max), dropped message counter for observability. ### Docs - **Worker options**: Documented 8 missing options: limiter, lockDuration, maxStalledCount, skipStalledCheck, skipLockRenewal, drainDelay, removeOnComplete, removeOnFail. - **FlowProducer BullMQ v5 API**: Documented add(), addBulk(), getFlow() methods with FlowJob/JobNode interfaces. - **Lifecycle functions**: Documented shutdownManager(), closeSharedTcpClient(), closeAllSharedPools(). - **Environment variables**: Added BUNQUEUE_MODE, BUNQUEUE_HOST, BUNQUEUE_PORT to env-vars reference. ## [2.6.25] - 2026-03-14 ### Fixed - **`GET /queues/:q/workers` crash**: Fixed crash when some workers were registered without a `queues` field (`undefined`/`null`). Now safely skips workers with missing queues and defaults to `[]` on creation. ## [2.6.24] - 2026-03-14 ### Fixed - **Per-queue completed count**: `GET /queues/:q/counts` `completed` field now counts only jobs completed in the requested queue instead of returning the global total across all queues. - **DLQ endpoint returns full metadata**: `GET /queues/:q/dlq` now returns `DlqEntry[]` with `enteredAt`, `reason`, `error`, `retryCount`, `lastRetryAt`, `nextRetryAt`, `expiresAt` instead of raw `Job[]`. - **Worker registration accepts `queue` (singular)**: `POST /workers` now accepts both `queue` (string) and `queues` (array), plus `workerId` as alias for `name`. ### Added - **Per-queue `totalCompleted`/`totalFailed` counters**: `GET /queues/:q/counts` now includes cumulative per-queue counters for completed and failed jobs. - **`GET /queues/:q/workers` endpoint**: New endpoint to list workers registered for a specific queue. - **`GET /queues/:q/dlq/stats` endpoint**: Server-side DLQ stats aggregation: `total`, `byReason`, `pendingRetry`, `oldestEntry`. - **Worker `concurrency`, `status`, `currentJob` fields**: `GET /workers` and `POST /workers` responses now include `concurrency`, computed `status` (active/stale), and `currentJob`. - **Throughput rates in `GET /stats`**: Added `pushPerSec`, `pullPerSec`, `completePerSec`, `failPerSec` from the built-in throughput tracker. ## [2.6.23] - 2026-03-14 ### Added - **Dashboard beta demo**: Added demo video and beta CTA to README and docs introduction page. ## [2.6.22] - 2026-03-14 ### Fixed - **dlq:added WebSocket event**: Now emitted when a job moves to DLQ after max attempts exceeded. Previously this event was defined but never fired. - **job:progress WebSocket event**: Progress value now included in event payload. Previously `progress` was `undefined` because the broadcast didn't set the top-level field. ### Added - **Comprehensive WebSocket pub/sub integration test**: 47 assertions covering all 9 event categories (job lifecycle, queue, DLQ, cron, worker, rate-limit, concurrency, webhook, config, system periodic) plus protocol tests (subscribe, unsubscribe, wildcard, invalid patterns, Ping over WS). ## [2.6.21] - 2026-03-14 ### Performance - **Batch push notifyBatch()**: Batch push now wakes all waiting workers correctly via `notifyBatch(N)` instead of a single `notify()` call. Each waiter is woken up individually, fixing a bug where only 1 of N workers received jobs immediately. - **Pre-compiled HTTP route regexes**: All 40+ regex patterns in HTTP route files are now compiled once at module load instead of per-request (~100µs/request savings). ### Security - **constantTimeEqual timing fix**: Removed early return on length mismatch that leaked token length via timing side-channel. - **Batch PUSHB data validation**: Individual job data size is now validated in batch push (was only checked in single PUSH), preventing 10MB limit bypass. - **Dashboard queue name validation**: `GET /dashboard/queues/:queue` now validates queue names like all other endpoints. - **Error message sanitization**: SQLite/database error messages are no longer leaked to clients in TCP and HTTP error responses. ### Fixed - **Silent error swallowing**: Replaced 7 empty `.catch(() => {})` blocks with proper error logging in addBatcher flush, sandboxed worker stop/kill/restart/heartbeat paths. ## [2.6.20] - 2026-03-14 ### Fixed - **Centralized HTTP JSON body parsing**: Replaced per-file `parseBody()` with shared `parseJsonBody()` that returns proper 400 responses for invalid JSON instead of silently falling back to `{}`. - **Dashboard pagination**: Added `limit` and `offset` query parameters to `GET /dashboard/queues`. Workers and crons lists capped at 100 entries with `truncated` flag. - **ESLint complexity reduction**: Extracted job push/pull/bulk operations into `routeJobOps()` helper to keep `routeQueueRoutes` under the 45-branch complexity limit. ## [2.6.19] - 2026-03-14 ### Added - **WebSocket idle timeout (ping/pong)**: Set `idleTimeout: 120` on the WebSocket server. Bun automatically sends ping frames and closes connections that don't respond with pong within 120 seconds. Dead clients (crash, network drop, kill -9) are now detected and cleaned up automatically instead of leaking in the clients Map forever. - **WebSocket max payload limit**: Set `maxPayloadLength: 1MB`. Prevents memory exhaustion from oversized messages. ## [2.6.18] - 2026-03-14 ### Added - **WebSocket pub/sub system with 50 event types**: Clients subscribe to specific events via `{ cmd: "Subscribe", events: ["job:*", "stats:snapshot"] }` and receive only matching data. Supports wildcard patterns (`*`, `job:*`, `queue:*`, `worker:*`, `dlq:*`, `cron:*`, etc.). Legacy clients (no Subscribe) continue receiving all events in the old format. - **Periodic dashboard broadcasts**: `stats:snapshot` every 5s (global stats, per-queue counts, throughput, workers), `health:status` every 10s (uptime, memory, connections), `storage:status` every 30s (collection sizes, disk health). - **`queue:counts` event**: Fired on every job state change with real-time counts for the affected queue. Eliminates the N+1 polling problem for dashboards (20 queues = 0 HTTP calls instead of 200+/min). - **Dashboard event hooks**: 30+ operations now emit real-time events: `job:promoted`, `job:discarded`, `job:priority-changed`, `job:data-updated`, `job:delay-changed`, `queue:paused/resumed/drained/cleaned/obliterated`, `dlq:retried/purged`, `cron:created/deleted`, `webhook:added/removed`, `ratelimit:set/cleared`, `concurrency:set/cleared`, `config:stall-changed/dlq-changed`, `worker:connected/disconnected`. ### Changed - **HTTP API docs rewritten**: 2,048 lines of enterprise-grade documentation with deep explanations of job lifecycle, retry behavior, stall detection, every endpoint with curl examples, full request/response specs, all 50 pub/sub events with payload schemas. ## [2.6.17] - 2026-03-14 ### Fixed - **Memory leak in HTTP client tracking**: Every HTTP PULL+ACK cycle created an orphaned entry in the `clientJobs` Map that was never cleaned up. Over time this grew unbounded. Fix: HTTP requests no longer set `clientId` (stateless). Job ownership tracking only applies to persistent connections (TCP/WebSocket). Orphaned HTTP jobs are handled by stall detection. ## [2.6.16] - 2026-03-14 ### Fixed - **PUSH `maxAttempts` silently ignored via HTTP**: The HTTP endpoint mapped `attempts` instead of `maxAttempts`, causing retry configuration to be discarded. Now correctly maps to `maxAttempts` (also accepts `attempts` for backwards compatibility). - **GetJobs pagination broken via HTTP**: The HTTP endpoint sent `start`/`end` instead of `offset`/`limit`, causing query parameters to be silently ignored. Pagination now works correctly. - **Batch HTTP endpoints unreachable**: `/jobs/ack-batch`, `/jobs/extend-locks`, and `/jobs/heartbeat-batch` were intercepted by the generic `/jobs/:id` pattern. Fixed by matching exact batch paths before the wildcard pattern. ## [2.6.15] - 2026-03-14 ### Added - **Full HTTP REST API parity with TCP protocol**: All 76 TCP commands are now accessible via HTTP endpoints. Previously only 17 endpoints were available. New endpoints include: - **Job management**: promote, update data, get state, get result, get/update progress, change priority, discard to DLQ, move to delayed, change delay, wait for completion, get children values - **Job logs**: add, get, and clear structured logs per job - **Job locking**: heartbeat, extend lock, batch heartbeat, batch extend locks - **Batch operations**: bulk push (`PUSHB`), batch pull (`PULLB`), batch acknowledge (`ACKB`) - **Queue control**: list queues, list jobs by state, job counts, priority counts, pause/resume, drain, obliterate, clean with grace period, promote all delayed, retry completed - **DLQ**: list DLQ jobs, retry (single or all), purge - **Rate limiting & concurrency**: set/clear per-queue rate limits and concurrency limits - **Queue configuration**: get/set stall detection config, get/set DLQ config - **Cron jobs**: full CRUD (list, add, get, delete) - **Webhooks**: full CRUD (list, add, remove, enable/disable) - **Workers**: list, register, unregister, worker heartbeat - **Monitoring**: ping, storage status - **HTTP route architecture**: Routes split into 4 files (`httpRouteJobs.ts`, `httpRouteQueues.ts`, `httpRouteQueueConfig.ts`, `httpRouteResources.ts`) for maintainability. - **HTTP API documentation rewritten**: Enterprise-grade docs with curl examples, full request/response specs, parameter tables, and error cases for every endpoint (1,640 lines). ## [2.6.14] - 2026-03-14 ### Fixed - **CLI double execution**: Every CLI command ran twice due to `main()` being called both on module load and on import. Added `import.meta.main` guard. - **CLI ACK/FAIL rejected UUID job IDs**: `parseBigIntArg()` only accepted numeric IDs (`/^\d+$/`) but all job IDs are UUIDs. Now accepts any non-empty string ID. - **CLI ACK/FAIL always failed**: Each CLI command opens a new TCP connection. When the PULL connection closed, jobs were auto-released back to waiting. ACK on a new connection found the job no longer in processing. Added `detach` flag to PULL command for CLI usage. - **`job get` showed `State: unknown`**: GetJob response didn't include job state. Now includes state from `getJobState()`. - **`queue jobs` state column showed `-`**: GetJobs handler didn't include state per job. Now injects state for each returned job. - **`bunqueue -p ` (without `start`) ignored port flag**: Direct mode ignored all CLI flags. Now routes to CLI parser when flags are present. - **Worker/webhook/cron/logs/metrics list showed `OK`**: Server wraps responses in `{data: {...}}` but CLI formatter only checked top-level keys. Added `unwrap()` helper. - **Cron list showed `OK`**: Server returns `crons` key but formatter checked for `cronJobs`. - **Worker/webhook list showed stats instead of entries**: `stats` check ran before `workers`/`webhooks` in formatter priority order. - **Worker register showed queue list**: Response `queues` field triggered queue list formatter. - **DLQ list format broken**: Formatter expected `jobId` field but server returns `id`. - **Metrics showed `OK`**: Prometheus metrics nested in `data.metrics`. ## [2.6.9] - 2026-03-10 ### Fixed - **SandboxedWorker graceful stop**: `stop()` now drains active jobs before terminating worker threads, preventing data loss when stopping during job processing. Added `force` parameter for immediate termination when needed. ([#39](https://github.com/egeominotti/bunqueue/issues/39)) ## [2.6.7] - 2026-03-08 ### Fixed - **CronScheduler stale heap bug**: When a cron job was removed, `scheduleNext()` encountered the stale heap entry and returned early without setting any timer, preventing all subsequent crons from firing. Now properly pops stale entries from the min-heap until a valid one is found. ([#33](https://github.com/egeominotti/bunqueue/issues/33)) - **Graceful shutdown burst load**: Fixed `worker.close(true)` causing unhandled AckBatcher errors when jobs were still completing during burst load scenarios. Changed to graceful close with proper drain. ### Added - **53 new test suites**: Comprehensive test coverage across embedded and TCP modes: - **Batch 1–3 (19 embedded + 18 TCP):** stress, ETL, retry, cron, queue group, shutdown, backpressure, priorities, lifecycle, data integrity, deduplication, timeouts, flows, removal, pause/resume, worker scaling, cancellation, DLQ patterns, bulk ops - **Coverage gap tests (16 embedded):** auto-batching, webhook delivery, durable jobs, rate limiting, lock race conditions, flow + stall detection, cron timezone/DST, LIFO queue, DLQ selective retry, S3 backup concurrent, webhook SSRF, MCP edge cases, CLI error formatting, flow deduplication, sandboxed worker + flow, queue group + flow - Total test count increased from ~4,000 to 4,903 ### Docs - Removed BullMQ-only WorkerOptions from API types (lockDuration, maxStalledCount, etc.) - Added auto-batching documentation to Queue guide - Added connection pool sizing note to Worker guide - Fixed CLI help: removed non-existent socket options, fake interactive prompts ### Performance - CronScheduler `scheduleNext()` now handles stale entries in O(k) amortized instead of blocking indefinitely ## [2.6.6] - 2026-03-07 ### Fixed - **Parent-child flow race condition**: Resolved race where concurrent ack/fail operations on parent-child flows could cause inconsistent state. ([#31](https://github.com/egeominotti/bunqueue/issues/31)) - **Embedded Worker heartbeats**: Fixed embedded Worker heartbeat mechanism not properly keeping jobs alive during long processing. ([#32](https://github.com/egeominotti/bunqueue/issues/32)) ## [2.6.5] - 2026-03-06 ### Fixed - **SandboxedWorker `log` event not emitted**: The processor's `job.log()` method stored logs via `addLog()` but the SandboxedWorker never emitted a `'log'` event. Listeners registered with `.on('log', ...)` were never called. Now properly emits `(job, message)` on each log call. ([#29](https://github.com/egeominotti/bunqueue/issues/29)) - **SandboxedWorker embedded heartbeats missing**: In embedded mode, `sendHeartbeat` was a no-op and `heartbeatInterval` defaulted to 0 (timer never started). Long-running jobs without `progress()` calls were detected as stalled and moved to DLQ despite still running. Now `sendHeartbeat` calls `manager.jobHeartbeat()` and defaults to 5000ms. ([#30](https://github.com/egeominotti/bunqueue/issues/30)) ### Added - Typed event overloads for `'log'` event on SandboxedWorker (`on`/`once`) - Regression tests for both issues (`test/issue29-sandboxed-log.test.ts`, `test/issue30-dlq-stall.test.ts`) ### Docs - Updated SandboxedWorker processor example with `log()`, `fail()`, and `parentId` fields - Fixed `heartbeatInterval` default from `0` to `5000` in embedded mode docs - Added `log` event to SandboxedWorker Event Reference (8 events total) - Added SandboxedWorker section to Stall Detection guide - Updated SandboxedWorkerOptions type with `heartbeatInterval` and `connection` fields ## [2.6.4] - 2026-03-05 ### Fixed - **Lock token race condition**: Resolved race where concurrent ack/fail operations could use an expired lock token, causing "Invalid or expired lock token" errors under high concurrency. ([#28](https://github.com/egeominotti/bunqueue/issues/28)) ### Added - **SandboxedWorker generics**: `SandboxedWorker` now supports a generic type parameter for typed events (e.g., `worker.on('completed', (job: Job) => ...)`) - **Processor API improvements**: Processor files now receive `log()`, `fail()`, and `parentId` on the job object alongside `progress()` - Typed `on()`/`once()` overloads for all SandboxedWorker events (#25) ## [2.6.2] - 2026-03-03 ### Fixed - **`job.name` always `'default'` for scheduled jobs**: When jobs were created via `Queue#upsertJobScheduler`, the `name` from `jobTemplate` was not embedded in the cron job data. The worker fell back to `'default'`. Now embeds the name in data, matching `Queue.add()` behavior. (Discussion #23) ### Added - Regression test for scheduler job name passthrough (`test/bug-23-scheduler-job-name.test.ts`) ### Docs - Added SandboxedWorker Options Reference table - Added SandboxedWorker Event Reference table with types - Clarified which events are not available on SandboxedWorker (`stalled`, `drained`, `cancelled`) - Added tip about increasing `maxMemory` for large file processing - Fixed missing `await` on `worker.start()` calls - Improved Worker vs SandboxedWorker comparison table ## [2.6.1] - 2026-03-03 ### Fixed - **`Queue#upsertJobScheduler` ignoring timezone**: The `RepeatOpts` interface was missing the `timezone` field, causing a TypeScript error when setting it. Additionally, embedded mode hardcoded `timezone: 'UTC'` and TCP mode did not forward timezone to the server. Now properly accepts and passes through IANA timezone strings (e.g., `"Europe/Rome"`, `"America/New_York"`). ([#22](https://github.com/egeominotti/bunqueue/issues/22)) ### Added - Regression test for scheduler timezone passthrough (`test/bug-22-scheduler-timezone.test.ts`) ## [2.6.0] - 2026-03-03 ### Added - **8 new TCP command handlers**: `ClearLogs`, `ExtendLock`, `ExtendLocks`, `ChangeDelay`, `SetWebhookEnabled`, `CompactMemory`, `MoveToWait`, `PromoteJobs`. These commands were already sent by the client SDK and MCP adapter but had no server-side handler, causing silent `Unknown command` errors in TCP mode. All 8 are now fully functional. - **`updateJobData` / `updateJobChildrenIds`** persistence methods added to `SqliteStorage` for parent-child relationship durability. - 20 new regression tests covering all fixes in this release. ### Fixed - **Expired lock requeue not updating stats**: When a job's lock expired and was requeued for retry, `requeueExpiredJob` in `lockManager.ts` did not call `shard.incrementQueued()` or `shard.notify()`. This caused `getStats()` to report 0 waiting jobs and workers in long-poll mode to not wake up for the requeued job. - **`updateJobParent` not persisting to SQLite**: `childrenIds` and `__parentId` mutations were only applied in memory. After a server restart, all parent-child flow relationships were lost. Now properly persisted via dedicated SQLite update methods. - **`getJob` returning null for completed jobs without storage**: In no-SQLite mode (embedded without persistence), `getJob()` returned `null` for completed/DLQ jobs because it only checked `ctx.storage?.getJob()`. Now falls back to `ctx.completedJobsData` in-memory map. - **MCP `UnregisterWorker` field mismatch**: MCP adapter sent `{ cmd: 'UnregisterWorker', id }` but the server expected `{ workerId }`. Worker unregistration via MCP in TCP mode always failed silently. Fixed to send the correct field name. - **`JobHeartbeat` ignoring `duration` field**: When the MCP adapter sent a `JobHeartbeat` with a custom `duration`, the handler ignored it and renewed the lock with the default TTL. Now properly extends the lock with the requested duration via `renewJobLock()`. ## [2.5.8] - 2026-03-02 ### Fixed - **Repeat job updateData**: `updateData()` now propagates to the next repeat execution. Previously, calling `updateData()` on a completed repeated job silently failed because the job was removed from the index. A repeat chain now tracks successor job IDs so updates reach the next scheduled execution. ([#16](https://github.com/egeominotti/bunqueue/issues/16)) - **Worker event IntelliSense**: Worker now has typed `on()` and `once()` overloads for all 10 events (`ready`, `active`, `completed`, `failed`, `progress`, `stalled`, `drained`, `error`, `cancelled`, `closed`), providing full TypeScript autocomplete. ([#15](https://github.com/egeominotti/bunqueue/issues/15)) ### Added - **`FlowJobData` type**: New exported interface for flow-injected fields (`__flowParentId`, `__flowParentIds`, `__parentId`, `__parentQueue`, `__childrenIds`). `Processor` now intersects `T` with `FlowJobData` for automatic IntelliSense in Worker callbacks. ([#18](https://github.com/egeominotti/bunqueue/issues/18)) - **CLI env var auth**: CLI now reads `BQ_TOKEN` / `BUNQUEUE_TOKEN` environment variables as fallback when `--token` is not provided. Priority: `--token` flag > `BQ_TOKEN` > `BUNQUEUE_TOKEN`. ([#13](https://github.com/egeominotti/bunqueue/issues/13)) ### Docs - Updated Worker guide with typed event reference table - Updated Flow guide with `FlowJobData` type documentation - Updated Queue guide with `updateData()` for repeatable jobs - Updated CLI guide and env vars guide with `BQ_TOKEN` / `BUNQUEUE_TOKEN` ## [2.5.7] - 2026-03-01 ### Added - **SandboxedWorker TCP mode**: SandboxedWorker now supports connecting to a remote bunqueue server via TCP, enabling crash-isolated job processing in server deployments (systemd, Docker). Pass `connection` option to enable it. - **SandboxedWorker EventEmitter**: SandboxedWorker now extends EventEmitter with full event support: `ready`, `active`, `completed`, `failed`, `progress`, `error`, `closed` (matching regular Worker API). - **QueueOps adapter** (`src/client/sandboxed/queueOps.ts`), unified interface for embedded and TCP queue operations, keeping SandboxedWorker code clean and dual-mode. - **TCP heartbeat for SandboxedWorker**: automatic lock renewal via `JobHeartbeat` commands for active jobs in TCP mode (configurable via `heartbeatInterval`). - TCP integration test for SandboxedWorker (`scripts/tcp/test-sandboxed-worker.ts`) - 8 new unit tests for SandboxedWorker events and TCP constructor ### Docs - Updated Worker guide with SandboxedWorker TCP mode section and events documentation - Updated CPU-Intensive Workers guide with SandboxedWorker TCP example ## [2.5.6] - 2026-02-27 ### Added - **3 new TCP commands** for MCP protocol optimization (73 tools total): - `CronGet`, fetch a single cron job by name instead of listing all and filtering client-side - `GetChildrenValues`, batch-fetch children return values in a single command instead of N+1 queries - `StorageStatus`, return real disk/storage health from the server instead of hardcoded `diskFull: false` - 9 new tests for the 3 TCP commands (`test/tcp-new-commands.test.ts`) ### Fixed - **MCP TCP `getCron(name)`**: now uses dedicated `CronGet` command instead of fetching all crons and filtering client-side - **MCP TCP `getChildrenValues(id)`**: now uses dedicated `GetChildrenValues` command instead of 1 + 2N queries (GetJob parent + GetResult/GetJob per child) - **MCP TCP `getStorageStatus()`**: now uses dedicated `StorageStatus` command instead of returning hardcoded `{ diskFull: false }` ## [2.5.5] - 2026-02-26 ### Fixed - **TCP client auth state corruption**: `TcpClient.doConnect()` set `connected = true` before `authenticate()` completed. If authentication failed, the client remained in a corrupted state (`connected = true` with no valid session), causing subsequent operations to silently fail. Connection state is now set only after successful authentication, with proper cleanup on failure. ### Docs - SEO overhaul, keyword-rich titles, optimized descriptions, AI keywords, sitemap priorities ## [2.5.4] - 2026-02-24 ### Added - **4 MCP Flow Tools**: job workflow orchestration via MCP (70 tools total): - `bunqueue_add_flow`, create flow trees with parent/children dependencies (BullMQ v5 compatible) - `bunqueue_add_flow_chain`, sequential pipelines: A → B → C - `bunqueue_add_flow_bulk_then`, fan-out/fan-in: parallel jobs → final merge - `bunqueue_get_flow`, retrieve flow trees with full dependency graph ## [2.5.3] - 2026-02-24 ### Added - **3 MCP Prompts** for AI agents, pre-built diagnostic templates: - `bunqueue_health_report`, comprehensive server health report with severity levels - `bunqueue_debug_queue`, deep diagnostic of a specific queue - `bunqueue_incident_response`, step-by-step triage playbook for "jobs not processing" ### Fixed - **MCP graceful shutdown**: `server.close()` now awaited before exit - **MCP `getStorageStatus()` TCP**: verifies server reachability instead of returning hardcoded response - **MCP `getChildrenValues()` TCP**: parallel fetch with `Promise.all` instead of sequential N+1 - **MCP resource error format**: includes `isError: true` consistent with tool errors - **MCP pool size**: configurable via `BUNQUEUE_POOL_SIZE` env var (default: 2) ## [2.5.2] - 2026-02-24 ### Fixed - **TCP deduplication**: `jobId` deduplication now works correctly in TCP mode. The auto-batcher was sending `jobId` instead of `customId` in PUSHB commands, causing the server to skip deduplication for all batched operations ([#10](https://github.com/egeominotti/bunqueue/issues/10)) - **CLI `--host` and `-p` flags**: `bunqueue start --host 127.0.0.1 -p 6666` now correctly binds to the specified host and port. Previously, `parseGlobalOptions()` consumed these flags as global options, removing them before the server could use them ([#9](https://github.com/egeominotti/bunqueue/issues/9)) - **Docker healthcheck**: Changed healthcheck URL from `localhost` to `127.0.0.1` to avoid IPv6 resolution issues in Alpine containers ([#7](https://github.com/egeominotti/bunqueue/issues/7)) - **TCP ping health check**: Fixed ping response parsing from `response.pong` to `response.data.pong` matching the actual server response structure ([#5](https://github.com/egeominotti/bunqueue/issues/5)) ### Added - Tests for PUSHB deduplication (same-batch and cross-batch) - Tests for CLI server argument re-injection (`--host`, `-p`, `--host=VALUE`, `--port=VALUE`) - Test for ping response structure validation - E2E TCP deduplication test script (`scripts/tcp/test-dedup-tcp.ts`) ### Docs - Updated deployment guide healthcheck example (`localhost` → `127.0.0.1`) - Clarified that `jobId` deduplication works in both embedded and TCP modes - Added `--host` flag example to CLI start command reference ## [2.5.1] - 2026-02-23 ### Fixed - **MCP error handling**: All 66 tool handlers now wrapped with `withErrorHandler` that catches backend exceptions and returns structured `{ error: "message" }` responses with `isError: true` instead of raw stack traces - **MCP TCP connection**: `createBackend()` is now async and properly awaits TCP connection. Previously used fire-and-forget (`void backend.connect()`) which silently swallowed connection failures - **MCP not-found responses**: `bunqueue_get_job`, `bunqueue_get_job_by_custom_id`, `bunqueue_get_progress`, and `bunqueue_get_cron` now return `isError: true` when resource is not found ### Added - `src/mcp/tools/withErrorHandler.ts`, Reusable error boundary for MCP tool handlers - 39 new MCP backend tests (75 total), webhooks, worker management, monitoring, batch operations, heartbeat, progress, full lifecycle ## [2.5.0] - 2026-02-21 ### Changed - **MCP server rewrite**: Upgraded from custom implementation to official `@modelcontextprotocol/sdk` (v1.26.0) for full protocol compliance - **66 tools** organized across 10 domain-specific files (jobTools, jobMgmtTools, consumptionTools, queueTools, dlqTools, cronTools, rateLimitTools, webhookTools, workerMgmtTools, monitoringTools) - **5 MCP resources** for read-only AI context (stats, queues, crons, workers, webhooks) - **Dual-mode backend**: Embedded (direct SQLite) and TCP (remote server) via `McpBackend` adapter interface ### Added - TCP mode for MCP server, connect to remote bunqueue server via `BUNQUEUE_MODE=tcp` - AI agent documentation and use cases - MCP configuration guides for Claude Desktop, Claude Code, Cursor, and Windsurf ## [2.4.8] - 2026-02-16 ### Fixed - **`getJobs({ state: 'completed' })`** now correctly returns completed jobs instead of empty results ## [2.4.7] - 2026-02-14 ### Performance - **Event-driven cron scheduler** - Replaced 1s `setInterval` polling with precise `setTimeout` that wakes exactly when the next cron is due. Zero wasted ticks between executions: | Scenario | Before | After | | ------------------ | --------------------------- | -------------------- | | 1 cron every 5min | 300 ticks/5min (299 wasted) | 1 tick/5min | | 0 crons registered | 1 tick/sec (all wasted) | 0 ticks | | Cron in 3 hours | 10,800 wasted ticks | 1 tick at exact time | - A 60s `setInterval` safety fallback catches edge cases (timer drift, missed events). Zero functional changes, zero API changes. ### Added - `scripts/embedded/test-cron-event-driven.ts` - Operational test verifying cron timer precision ## [2.4.6] - 2026-02-14 ### Performance - **Event-driven dependency resolution** - Replaced 100ms `setInterval` polling with microtask-coalesced flush triggered on job completion. Dependency chain latency drops from hundreds of milliseconds to microseconds: | Scenario | Before (P50) | After (P50) | Speedup | | --------------------- | ------------ | ------------ | ------------ | | Single dep (A→B) | 100.05ms | 12.5µs | **~8,000x** | | Chain (4 levels) | 300.43ms | 28.2µs | **~10,700x** | | Fan-out (1→5) | 100.11ms | 31.0µs | **~3,200x** | - The previous 100ms interval is now a 30s safety fallback. Zero functional changes, zero API changes. - Bonus: less CPU at idle (no more 10 calls/sec to `processPendingDependencies` when queue is empty). ### Added - `src/benchmark/dependency-latency.bench.ts` - Benchmark for dependency chain resolution latency - `src/application/taskErrorTracking.ts` - Extracted error tracking for reuse across modules ## [2.4.5] - 2026-02-14 ### Fixed - **Backoff jitter** - `calculateBackoff()` now applies jitter to prevent thundering herd when many jobs retry simultaneously. Exponential backoff uses ±50% jitter, fixed backoff uses ±20% jitter around the configured delay. - **Backoff max cap** - Retry delays are now capped at 1 hour (`DEFAULT_MAX_BACKOFF = 3,600,000ms`) by default. Previously, attempt 20 with 1000ms base produced ~12 day delays. Configurable via `BackoffConfig.maxDelay`. - **Recovery backoff bypass** - Startup recovery now uses `calculateBackoff(job)` instead of an inline exponential formula, correctly respecting `backoffConfig` (e.g., `{ type: 'fixed', delay: 5000 }` was ignored during recovery). ## [2.4.3] - 2026-02-14 ### Fixed - **Batch push now wakes all waiting workers** - `pushJobBatch` previously called `notify()` only once, causing only 1 of N waiting workers to wake up immediately. Others had to wait for their poll timeout (up to 30s with long-poll). Now each inserted job triggers a separate notification, waking all idle workers instantly. - **Pending notifications counter** - `WaiterManager.pendingNotification` was a boolean flag, silently losing notifications when multiple pushes occurred with no waiting workers. Changed to an integer counter (`pendingNotifications`) so each notification is tracked and consumed individually. ## [2.4.2] - 2026-02-13 ### Added - **CPU-Intensive Workers guide** - New dedicated docs page for handling CPU-heavy jobs over TCP - Explains the ping health check failure chain that causes job loss after ~90s of CPU load - Connection tuning: `pingInterval: 0`, `commandTimeout: 60000` - Non-blocking CPU patterns with `await Bun.sleep(0)` yield - Default timeouts reference table - SandboxedWorker as alternative for truly CPU-bound work - **CPU stress test script** - `scripts/stress-cpu-intensive.ts` (500 jobs, 5 CPU task types, concurrency 3) ## [2.4.1] - 2026-02-12 ### Changed - **Codebase refactoring** - Split 6 large files exceeding 300-line limit into smaller focused modules - `src/shared/lru.ts` (643 lines) → barrel re-export + 5 modules: `lruMap.ts`, `lruSet.ts`, `boundedSet.ts`, `boundedMap.ts`, `ttlMap.ts` - `src/client/jobConversion.ts` (499 lines) → 269 lines + `jobConversionTypes.ts`, `jobConversionHelpers.ts` - `src/domain/queue/shard.ts` (554 lines) → 484 lines + `waiterManager.ts`, `shardCounters.ts` - `src/application/queueManager.ts` (820 lines) → 774 lines (moved `getQueueJobCounts` to `statsManager.ts`) - `src/client/worker/worker.ts` (843 lines) → 596 lines + `workerRateLimiter.ts`, `workerHeartbeat.ts`, `workerPull.ts` - All barrel re-exports preserve backward compatibility, zero breaking changes - 12 new files created, 6 files modified ## [2.4.0] - 2026-02-11 ### Added - **Auto-batching for `queue.add()` over TCP** - Transparently batches concurrent `add()` calls into `PUSHB` commands - Zero overhead for sequential `await` usage (flush immediately when idle) - ~3x speedup for concurrent adds (buffers during in-flight flush) - Configurable: `autoBatch: { maxSize: 50, maxDelayMs: 5 }` (defaults) - Durable jobs bypass the batcher (sent as individual PUSH) - Disable with `autoBatch: { enabled: false }` - **306 new tests** covering previously untested modules ## [2.3.1] - 2026-02-08 ### Fixed - **Non-numeric job IDs** - Allow non-numeric job IDs in HTTP routes - Updated HTTP route tests to match non-numeric job ID support ## [2.3.0] - 2026-02-06 ### Added - **Latency Histograms** - Prometheus-compatible histograms for push, pull, and ack operations - Fixed bucket boundaries: 0.1ms to 10,000ms (15 buckets) - Full exposition format: `_bucket{le="..."}`, `_sum`, `_count` - Percentile calculation (p50, p95, p99) for SLO tracking - New files: `src/shared/histogram.ts`, `src/application/latencyTracker.ts` - **Per-Queue Metric Labels** - Prometheus labels for per-queue drill-down - `bunqueue_queue_jobs_waiting{queue="..."}` (waiting, delayed, active, dlq) - Enables Grafana filtering and alerting per queue name - **Throughput Tracker** - Real-time EMA-based rate tracking - `pushPerSec`, `pullPerSec`, `completePerSec`, `failPerSec` - O(1) per observation, zero GC pressure - Replaces placeholder zeros in `/stats` endpoint - New file: `src/application/throughputTracker.ts` - **LOG_LEVEL Runtime Filtering** - `LOG_LEVEL` env var now works at runtime - Levels: `debug`, `info` (default), `warn`, `error` - Priority-based filtering with early return - **39 new telemetry tests** across 5 test files: - `test/histogram.test.ts` (9 tests) - `test/latencyTracker.test.ts` (7 tests) - `test/perQueueMetrics.test.ts` (7 tests) - `test/throughputTracker.test.ts` (7 tests) - `test/telemetry-e2e.test.ts` (9 E2E integration tests) ### Changed - `/stats` endpoint now returns real throughput and latency values - Monitoring docs updated with per-queue metrics, histogram examples, and logging section - HTTP API docs updated with new Prometheus output format ### Performance - Telemetry overhead: ~0.003% (~25ns per operation via `Bun.nanoseconds()`) - Benchmark results unchanged: 197K push/s (embedded), 39K push/s (TCP) ## [2.1.8] - 2026-02-06 ### Fixed - **pushJobBatch event emission** - `pushJobBatch` was silently dropping event broadcasts, causing subscribers and webhooks to miss all batch-pushed jobs. Added broadcast loop after batch insert to match single `pushJob` behavior. ### Added - 4 regression tests for batch push event emission fix ### Changed - Navbar simplified to show only logo without title text ## [2.1.7] - 2026-02-05 ### Fixed - **WriteBuffer silent data loss during shutdown** - `WriteBuffer.stop()` swallowed flush errors and silently dropped buffered jobs. Added `reportLostJobs()` to notify via `onCriticalError` callback when jobs cannot be persisted during shutdown. - **Queue name consistency in TCP tests** - Fixed port hardcoding in queue-name-consistency test. ### Added - **2,664 new tests across 37 files** - Comprehensive test coverage increase from 1,083 to 3,747 tests (+246%) with zero failures. Coverage spans core operations, data structures, managers, client TCP layer, server handlers, domain types, MCP handlers, and more. ## [2.1.6] - 2026-02-05 ### Fixed - **S3 backup hardening** - 10 bug fixes with 33 new tests: - Replace silent catch in cleanup with proper logging - Reject retention < 1 and intervalMs < 60s in config validation - Validate SQLite magic bytes before restore to prevent data corruption - Guard cleanup against retention=0 deleting all backups - Add S3 list pagination to handle >100 backups - Run WAL checkpoint before backup to include uncheckpointed data - Replace blocking gzipSync/gunzipSync with async CompressionStream - **Flaky sandboxedWorker concurrent test** - Poll all 4 job results in parallel instead of sequentially to avoid exceeding the 5s test timeout. ### Added - 33 new S3 backup tests covering config validation, backup/restore operations, cleanup, and manager lifecycle - Documentation for gzip compression, SHA256 checksums, `.meta.json` files, scheduling details, AWS env var aliases, and restore safety notes ## [2.1.5] - 2026-02-05 ### Fixed - **uncaughtException and unhandledRejection handlers** - Previously, any uncaught error in background tasks or unhandled promise rejections would crash the server immediately without cleanup (write buffer not flushed, SQLite not closed, locks not released). Now the server performs graceful shutdown: logs the error with stack trace, stops TCP/HTTP servers, waits for active jobs, flushes the write buffer, and exits cleanly. - Broken GitHub links in documentation (missing `/bunqueue` in paths) - Stray separator in index.mdx causing build error ### Changed - Migrated documentation from GitHub Pages to Vercel deployment - SEO optimization across all 45 pages with improved titles and descriptions - Documentation errors fixed, missing content added, and navbar modernized ## [2.1.4] - 2026-02-05 ### Changed - README split into Embedded and Server mode sections - Added Docker server mode quick start with persistence documentation ## [2.1.3] - 2026-02-05 ### Added - **Type safety improvements** across client SDK - Deployment modes section and fixed quick start examples in documentation ### Changed - README improved with use cases, benchmarks, and BullMQ comparison ## [2.1.2] - 2026-02-04 ### Fixed - **Queue name consistency** - Fixed benchmark tests using different queue names for worker and queue in both embedded and TCP modes ### Changed - Stats interval changed to 5 minutes with timestamp - Removed verbose info/warn logs, keeping only errors - Downgraded TypeScript to 5.7.3 for CI compatibility ### Added - Queue name consistency tests to prevent regression - Monitoring documentation added to sidebar Production section ## [2.1.1] - 2026-02-04 ### Added - **Prometheus + Grafana Monitoring Stack** - Complete observability setup: - Docker Compose profile for one-command monitoring deployment - Pre-configured Prometheus scraping with 5s interval - Comprehensive Grafana dashboard with 6 panel rows: - Overview: Waiting, Delayed, Active, Completed, DLQ, Workers, Cron, Uptime - Throughput: Jobs/sec graphs, queue depth over time - Success/Failure: Rate gauges, completed vs failed charts - Workers: Count, throughput, utilization gauge - Webhooks & Cron: Status and lifetime totals - Alerts: Visual indicators for DLQ, failure rate, backlog, workers - 8 pre-configured Prometheus alert rules: - `BunqueueDLQHigh` - DLQ > 100 for 5m (critical) - `BunqueueHighFailureRate` - Failure > 5% for 5m (warning) - `BunqueueQueueBacklog` - Waiting > 10k for 10m (warning) - `BunqueueNoWorkers` - No workers with waiting jobs (critical) - `BunqueueServerDown` - Server unreachable (critical) - `BunqueueLowThroughput` - < 1 job/s for 10m (warning) - `BunqueueWorkerOverload` - Utilization > 95% (warning) - `BunqueueJobsStuck` - Active jobs, no completions (warning) - **Monitoring Documentation** - New guide at `/guide/monitoring/` ### Changed - Docker Compose now supports `--profile monitoring` for optional stack ## [2.1.0] - 2026-02-04 ### Performance - **TCP Pipelining** - Major throughput improvement for TCP client operations: - Client-side: Multiple commands in flight per connection (up to 100 by default) - Server-side: Parallel command processing with `Promise.all()` - reqId-based response matching for correct command-response pairing - **125,000 ops/sec** in pipelining benchmarks (vs ~20,000 before) - Configurable via `pipelining: boolean` and `maxInFlight: number` options - **SQLite indexes for high-throughput operations** - Added 4 new indexes for 30-50% faster queries: - `idx_jobs_state_started`: Stall detection now O(log n) instead of O(n) table scan - `idx_jobs_group_id`: Fast lookup for group operations - `idx_jobs_pending_priority`: Compound index for priority-ordered job retrieval - `idx_dlq_entered_at`: DLQ expiration cleanup now O(log n) - **Date.now() caching in pull loop** - Reduced syscalls by caching timestamp per iteration (+3-5% throughput) ### Added - **Hello command** for protocol version negotiation (`cmd: 'Hello'`) - **Protocol version 2** with pipelining capability support - **Semaphore utility** for server-side concurrency limiting (`src/shared/semaphore.ts`) - Comprehensive pipelining test suites: - `test/protocol-reqid.test.ts` - 7 tests for reqId handling - `test/client-pipelining.test.ts` - 7 tests for client pipelining - `test/server-pipelining.test.ts` - 7 tests for server parallel processing - `test/backward-compat.test.ts` - 10 tests for backward compatibility - **Fair benchmark comparison** (`bench/comparison/run.ts`): - Both bunqueue and BullMQ use identical parallel push strategy - Queue cleanup with `obliterate()` between tests - Results: **1.3x Push**, **3.2x Bulk Push**, **1.7x Process** vs BullMQ - **Comprehensive benchmark** (`bench/comprehensive.ts`): - Embedded vs TCP mode comparison at scales [1K, 5K, 10K, 50K] - Log suppression for clean output - Peak results: **287K ops/sec** (Embedded Bulk), **149K ops/sec** (TCP Bulk) - Embedded mode is **2-4x faster** than TCP across all operations - **New ConnectionOptions** - Added `pingInterval`, `commandTimeout`, `pipelining`, `maxInFlight` to public API ### Fixed - **SQLITE_BUSY under high concurrency** - Added `PRAGMA busy_timeout = 5000` to wait for locks instead of failing immediately - **"Database has closed" errors during shutdown** - Added `stopped` flag to WriteBuffer to prevent flush attempts after stop() - **Critical: Worker pendingJobs race condition** - Concurrent `tryProcess()` calls could overwrite each other's job buffers, causing ~30% job loss under high concurrency. Now preserves existing buffered jobs when pulling new batches. - **Connection options not passed through** - Worker, Queue, and FlowProducer now correctly pass `pingInterval`, `commandTimeout`, `pipelining`, and `maxInFlight` options to the TCP connection pool. ### Changed - Schema version bumped to 5 (auto-migrates existing databases) - TCP client now includes `reqId` in all commands for response matching - Server processes multiple frames in parallel (max 50 concurrent per connection) - **Documentation**: Rewrote comparison page with real benchmark data and methodology explanation ## [2.0.9] - 2026-02-03 ### Fixed - **Critical: Memory leak in EventsManager** - Cancelled waiters in `waitForJobCompletion()` were never removed from the `completionWaiters` map on timeout. Now properly cleaned up when timeout fires. - **Critical: Lost notification TOCTOU race** - Fixed race condition in pull.ts where `notify()` could fire between `tryPullFromShard()` returning null and `waitForJob()` being called. Added `pendingNotification` flag to Shard to capture notifications when no waiters exist. - **Critical: WriteBuffer data loss** - Added exponential backoff (100ms → 30s), max 10 retries, critical error callback, `stopGracefully()` method, and enhanced error callback with retry information. Previously, persistent errors caused infinite retries and shutdown lost pending jobs. - **Critical: CustomIdMap race condition** - Concurrent pushes with same customId could create duplicates. Moved customIdMap check inside shard write lock for atomic check-and-insert. ### Added - Comprehensive test suites for all bug fixes: - `test/bug-memory-leak-waiters.test.ts` - 5 tests verifying memory leak fix - `test/bug-lost-notification.test.ts` - 4 tests verifying notification fix - `test/bug-writebuffer-dataloss.test.ts` - 10 tests verifying WriteBuffer fix - `test/bug-verification-remaining.test.ts` - 7 tests verifying CustomId fix and JS concurrency model ## [2.0.3] - 2026-02-02 ### Changed - **Major refactor: Split queue.ts into modular architecture** (1955 → 485 lines) - Follows single responsibility principle with 14 focused modules - New modules: operations/add.ts, operations/counts.ts, operations/query.ts, operations/management.ts, operations/cleanup.ts, operations/control.ts - New modules: jobMove.ts, jobProxy.ts, bullmqCompat.ts, scheduler.ts, dlq.ts, stall.ts, rateLimit.ts, deduplication.ts, workers.ts, queueTypes.ts - All 894 unit tests, 25 TCP test suites, and 32 embedded test suites pass ### Fixed - `getJob()` now properly awaits async manager.getJob() call - `getJobCounts()` now uses queue-specific counts instead of global stats - `promoteJobs()` implements correct iteration over delayed jobs - `addBulk()` properly passes BullMQ v5 options (lifo, stackTraceLimit, keepLogs, etc.) - `toPublicJob()` used for full job options support in getJob() - `extendJobLock()` passes token parameter correctly ## [2.0.2] - 2026-02-02 ### Fixed - **Critical: Complete recovery logic for deduplication after restart** - Fixed all recovery scenarios that caused duplicate jobs after server restart: - **jobId deduplication** (`customIdMap`) - Now properly populated on recovery - **uniqueKey TTL deduplication** - Now restored with TTL settings via `registerUniqueKeyWithTtl()` - **Dependency recovery** - Now checks SQLite `job_results` table (not just in-memory `completedJobs`) - **Counter consistency** - Fixed `incrementQueued()` only called for main queue jobs, not `waitingDeps` ### Added - `loadCompletedJobIds()` method in SQLite storage for dependency recovery - `hasResult()` method to check if job result exists in SQLite - Comprehensive recovery test suite (`test/recoveryLogic.test.ts`) with 8 tests covering all scenarios ## [2.0.1] - 2026-02-02 ### Fixed - **Critical: jobId deduplication not working after restart** - The `customIdMap` was not populated when recovering jobs from SQLite on server startup. This caused `getDeduplicationJobId()` to return `null` and allowed duplicate jobs with the same `jobId` to be created. ## [2.0.0] - 2026-02-02 ### Added - **Complete BullMQ v5 API Compatibility** - Full feature parity with BullMQ v5 - **Worker Advanced Methods** - `rateLimit(expireTimeMs)` - Apply rate limiting to worker - `isRateLimited()` - Check if worker is currently rate limited - `startStalledCheckTimer()` - Start stalled job check timer - `delay(ms, abortController?)` - Delay worker processing with optional abort - **Job Advanced Methods** - `discard()` - Mark job as discarded - `getFailedChildrenValues()` - Get failed children job values - `getIgnoredChildrenFailures()` - Get ignored children failures - `removeChildDependency()` - Remove child dependency from parent - `removeDeduplicationKey()` - Remove deduplication key - `removeUnprocessedChildren()` - Remove unprocessed children jobs - **JobOptions** - `continueParentOnFailure` - Continue parent job when child fails - `ignoreDependencyOnFailure` - Ignore dependency on failure - `timestamp` - Custom job timestamp - **DeduplicationOptions** - `extend` - Extend TTL on duplicate - `replace` - Replace existing job on duplicate - **Comprehensive Test Coverage** - 27 unit tests + 32 embedded script tests for new features ### Changed - Major version bump to 2.0.0 reflecting complete BullMQ v5 compatibility - Updated TypeScript types for all new features ## [1.9.9] - 2026-02-01 ### Added - **Comprehensive Functional Test Suite** - 28 new test files covering all major features - 14 embedded mode tests + 14 TCP mode tests - Tests for: advanced DLQ, job management, monitoring, rate limiting, stall detection, webhooks, queue groups, and more - All 24 embedded test suites pass (143/143 individual tests) ### Changed - **BullMQ-Style Idempotency** - `jobId` option now returns existing job instead of throwing error - Duplicate job submissions are idempotent (same behavior as BullMQ) - Cleaner handling of retry scenarios without error handling - Improved documentation for `jobId` deduplication behavior ### Fixed - Embedded test suite now properly uses embedded mode (was incorrectly trying TCP) - Fixed `getJobCounts()` in tests to use queue-specific `getJobs()` method - Fixed async `getJob()` calls in job management tests - Fixed PROMOTE, CHANGE PRIORITY, and MOVE TO DELAYED test logic ## [1.9.8] - 2026-01-31 ### Changed - **msgpackr Binary Protocol** - Switched TCP protocol from JSON to msgpackr binary - ~30% faster serialization/deserialization - Smaller message sizes ## [1.9.6] - 2026-01-31 ### Added - **Durable Writes** - New `durable: true` option for critical jobs - Bypasses write buffer for immediate disk persistence - Guarantees no data loss on process crash - Use for payments, orders, and critical events ### Changed - **Reduced write buffer flush interval** from 50ms to 10ms - Smaller data loss window for non-durable jobs - Better balance between throughput and safety ## [1.9.4] - 2026-01-31 ### Added - **5 BullMQ-Compatible Features** - **Timezone support for cron jobs** - IANA timezones (e.g., "Europe/Rome", "America/New_York") - **`getCountsPerPriority()`** - Get job counts grouped by priority level - **`getJobs()` with pagination** - Filter by state, paginate with `start`/`end`, sort with `asc` - **`retryCompleted()`** - Re-queue completed jobs for reprocessing - **Advanced deduplication** - TTL-based unique keys with `extend` and `replace` strategies ### Changed - **Documentation improvements** - Clear comparison table for Embedded vs TCP Server modes - Danger box warning about mixed modes causing "Command timeout" error - Added "Connecting from Client" section to Server guide ## [1.9.3] - 2026-01-31 ### Added - **Unix Socket Support** - TCP and HTTP servers can now bind to Unix sockets - Configure via `TCP_SOCKET_PATH` and `HTTP_SOCKET_PATH` environment variables - CLI flags `--tcp-socket` and `--http-socket` - Lower latency for local connections - Socket status line in startup banner ### Fixed - Test alignment for shard drain return type ## [1.9.2] - 2026-01-30 ### Fixed - **Critical Memory Leak** - Resolved `temporalIndex` leak causing 5.5M object retention after 1M jobs - Added `cleanOrphanedTemporalEntries()` method to Shard - Memory now properly released after job completion with `removeOnComplete: true` - `heapUsed` drops to ~6MB after processing (vs 264MB before fix) ### Changed - Improved error logging in ackBatcher flush operations ## [1.9.1] - 2026-01-29 ### Added - **Two-Phase Stall Detection** - BullMQ-style stall detection to prevent false positives - Jobs marked as candidates on first check, confirmed stalled on second - Prevents requeuing jobs that complete between checks - `stallTimeout` support in client push options - Advanced health checks for TCP connections ### Fixed - Defensive checks and cleanup for TCP pool and worker - Server banner alignment between CLI and main.ts ### Changed - Modularized client code into separate TCP, Worker, Queue, and Sandboxed modules ## [1.9.0] - 2026-01-28 ### Added - **TCP Client** - High-performance TCP client for remote server connections - Connection pooling with configurable pool size - Heartbeat keepalive mechanism - Batch pull/ACK operations (PULLB, ACKB with results) - Long polling support - Ping/pong health checks - 4.7x faster push throughput with optimized TCP client ### Changed - Connection pool enabled by default for TCP clients - Improved ESLint compliance across TCP client code ## [1.6.8] - 2026-01-27 ### Fixed - Renamed bunq to bunqueue in Dockerfile - CLI version now read dynamically from package.json ### Changed - Centralized version in `shared/version.ts` ## [1.6.7] - 2026-01-26 ### Added - Dynamic version badge in documentation - Mobile-responsive layout improvements - Comprehensive stress tests ## [1.6.6] - 2026-01-25 ### Fixed - Counter updates when recovering jobs from SQLite on restart ## [1.6.5] - 2026-01-24 ### Fixed - Production readiness improvements with critical fixes ## [1.6.4] - 2026-01-23 ### Fixed - SQLite persistence for DLQ entries - Client SDK persistence issues ## [1.6.3] - 2026-01-22 ### Added - **MCP Server** - Model Context Protocol server for AI assistant integration - Queue management tools for Claude, Cursor, and other AI assistants - BigInt serialization handling in stats ### Fixed - Deployment guide documentation corrections ## [1.6.2] - 2026-01-21 ### Added - **SandboxedWorker** - Isolated worker processes for crash protection - Hono and Elysia integration guides - Section-specific OG images and sitemap ### Changed - Enhanced SEO with Open Graph and Twitter meta tags - Improved mobile responsiveness in documentation ## [1.6.1] - 2026-01-20 ### Added - Bunny ASCII art in server startup and CLI help - Professional benchmark charts using QuickChart.io - BullMQ vs bunqueue comparison benchmarks ### Changed - Optimized event subscriptions and batch operations - Replaced Math.random UUID with Bun.randomUUIDv7 (10x faster) - High-impact algorithm optimizations ## [1.6.0] - 2026-01-19 ### Added - **Stall Detection** - Automatic recovery of unresponsive jobs - Configurable stall interval and max stalls - Grace period after job start - Automatic retry or move to DLQ - **Advanced DLQ** - Enhanced Dead Letter Queue - Full metadata (reason, error, attempt history) - Auto-retry with exponential backoff - Filtering by reason, age, retriability - Statistics endpoint - Auto-purge expired entries - **Worker Heartbeats** - Configurable heartbeat interval - **Repeatable Jobs** - Support for recurring jobs with intervals or limits - **Flow Producer** - Parent-child job relationships - **Queue Groups** - Bulk operations across multiple queues ### Changed - Updated banner to "written in TypeScript" - Version now read from package.json dynamically ### Fixed - DLQ entry return type consistency ## [1.5.0] - 2026-01-15 ### Added - S3 backup with configurable retention - Support for Cloudflare R2, MinIO, DigitalOcean Spaces - Backup CLI commands (now, list, restore, status) ### Changed - Improved backup compression - Better error messages for S3 configuration ## [1.4.0] - 2026-01-10 ### Added - Rate limiting per queue - Concurrency limiting per queue - Prometheus metrics endpoint - Health check endpoint ### Changed - Optimized batch operations (3x faster) - Reduced memory usage for large queues ## [1.3.0] - 2026-01-05 ### Added - Cron job scheduling - Webhook notifications - Job progress tracking - Job logs ### Fixed - Memory leak in event listeners - Race condition in batch acknowledgment ## [1.2.0] - 2025-12-28 ### Added - Priority queues - Delayed jobs - Retry with exponential backoff - Job timeout ### Changed - Improved SQLite schema with indexes - Better error handling ## [1.1.0] - 2025-12-20 ### Added - TCP protocol for high-performance clients - HTTP API with WebSocket support - Authentication tokens - CORS configuration ## [1.0.0] - 2025-12-15 ### Added - Initial release - Queue and Worker classes - SQLite persistence with WAL mode - Basic DLQ support - CLI for server and client operations --- # Security: Authentication, TLS & Hardening The bunqueue security model: token authentication, native TLS, network isolation, abuse protection and how to report a vulnerability. URL: https://bunqueue.dev/security/ import { Tabs, TabItem } from '@astrojs/starlight/components';
reference · security

Security, hardened by default.

The bunqueue security model: the defaults you get out of the box, the controls available to harden a deployment, and how to report vulnerabilities. Every statement on this page reflects the current codebase.

## Reporting vulnerabilities **Do not open a public issue for security vulnerabilities.** Report privately through either channel: - Email: **security@bunqueue.dev** - GitHub private vulnerability reporting: open the [Security tab](https://github.com/egeominotti/bunqueue/security) and select "Report a vulnerability" You will receive an acknowledgement within 48 hours. Fixes ship as patch releases and are announced through GitHub Security Advisories and the npm advisory database. ## Security model A bunqueue server exposes two listeners: the TCP protocol on port 6789, used by every client SDK, and the HTTP API on port 6790, used for health, metrics, dashboards and the REST surface. Both listeners share the same token and TLS configuration, with explicit public health and metrics exceptions described below. Each broker is one process, and a shared SQLite server or PostgreSQL namespace is one trust domain: any authenticated client can operate on any queue. Multi tenant isolation, when required, is achieved by running one instance per tenant, or by namespacing queues with `prefixKey` where the boundary is organizational rather than adversarial. ## Authentication Authentication is token based and disabled until you configure it. When `AUTH_TOKENS` is set, every TCP connection must authenticate as its first command. HTTP API and debug requests require the bearer token, including `/gc` and `/heapstats`. The orchestrator probes `/health`, `/healthz`, `/live`, and `/ready` intentionally remain public. `/prometheus` is public by default and requires the same bearer token only when `METRICS_AUTH=true`; that setting with an empty token set fails closed. ```bash AUTH_TOKENS=$(openssl rand -hex 32) bunqueue start ``` Or through the [configuration file](/guide/configuration/): ```typescript // bunqueue.config.ts import { defineConfig } from 'bunqueue'; export default defineConfig({ auth: { tokens: [process.env.AUTH_TOKEN!] }, }); ``` Clients pass the token in their connection options, identically across languages: ```typescript import { Queue } from 'bunqueue/client'; const queue = new Queue('emails', { connection: { host: 'q.internal', token: process.env.BUNQUEUE_TOKEN }, }); ``` ```typescript import { Queue } from 'bunqueue-client'; const queue = new Queue('emails', { host: 'q.internal', token: process.env.BUNQUEUE_TOKEN }); ``` ```python queue = Queue("emails", host="q.internal", token=os.environ["BUNQUEUE_TOKEN"]) ``` ```php $queue = new Queue('emails', [ 'host' => 'q.internal', 'token' => getenv('BUNQUEUE_TOKEN'), ]); ``` ```go queue := bunqueue.NewQueue("emails", bunqueue.Options{ Host: "q.internal", Token: os.Getenv("BUNQUEUE_TOKEN"), }) ``` ```rust use bunqueue_client::{ConnectionOptions, Queue}; let queue = Queue::new("emails", ConnectionOptions { host: "q.internal".into(), token: std::env::var("BUNQUEUE_TOKEN").ok(), ..Default::default() }); ``` ```elixir queue = Bunqueue.queue("emails", host: "q.internal", token: System.fetch_env!("BUNQUEUE_TOKEN") ) ``` Multiple tokens are supported (`AUTH_TOKENS=token1,token2`), which enables zero downtime rotation: add the new token, roll clients over, remove the old one. `/prometheus` can additionally be gated with `METRICS_AUTH=true`. ## Transport security Three options, in order of preference for typical deployments: 1. **Native TLS on both listeners.** Provide a certificate and key and both the TCP protocol and the HTTP API serve TLS directly, no proxy required. Partial configuration (one variable without the other) is a startup error, not a silent downgrade. ```bash TLS_CERT_FILE=./cert.pem TLS_KEY_FILE=./key.pem bunqueue start ``` Clients verify against system certificate authorities by default and accept a custom CA bundle (`tls: { caFile: './ca.pem' }`). Disabling verification is possible for development only. See the [TLS guide](/guide/tls/). 2. **A Unix domain socket for the HTTP API** on same host deployments, where access control reduces to filesystem permissions. The TCP protocol has no Unix-socket support today (`TCP_SOCKET_PATH` is reserved but not applied), so bind it to loopback: ```bash HTTP_SOCKET_PATH=/run/bunqueue/http.sock HOST=127.0.0.1 bunqueue start ``` 3. **A reverse proxy** (nginx, Caddy) terminating TLS in front of the HTTP API, with the server bound to localhost. ## Network exposure and defaults Defaults favor a working local setup. Review this table before exposing an instance beyond a trusted network: | Setting | Default | Production recommendation | | ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `AUTH_TOKENS` | unset, no authentication | Always set; rotate with multiple tokens | | `HOST` | `0.0.0.0`, all interfaces | Bind to `127.0.0.1` or a private interface unless remote clients need direct access | | TLS | disabled | Enable native TLS or terminate at a proxy | | `CORS_ALLOW_ORIGIN` | unset, no cross origin access is granted | Set explicitly, and only to your dashboard origins, when a browser client needs the HTTP API | | `METRICS_AUTH` | `false`, `/prometheus` is public | Set `true` if metrics may leak operational detail; with no `AUTH_TOKENS`, `/prometheus` fails closed with 503 | | Protocol rate limit | 10,000 requests per 60 s per client | Tune with `RATE_LIMIT_MAX_REQUESTS` / `RATE_LIMIT_WINDOW_MS` | ## Abuse protection - **Protocol rate limiting.** A sliding window limiter caps requests per client on the wire, 10,000 per 60 seconds by default, configurable via environment variables. - **Frame size cap.** TCP frames are limited to 64 MB; oversized frames are rejected before allocation, preventing memory exhaustion. - **Per queue controls.** Rate limits and global concurrency caps can be set per queue at runtime (`RateLimit`, `SetConcurrency`). - **Webhook SSRF protection.** Webhook URLs are validated before registration: only `http`/`https`, no localhost or loopback, no private IPv4 ranges, no IPv6 unique local, link local or IPv4 mapped bypasses, and no cloud metadata endpoints. Invalid targets are rejected at `AddWebhook` time. - **Input validation.** Queue names are restricted to a safe character set, job payloads are capped at 10 MB, and numeric options are bounds checked server side. - **Error redaction.** TCP/HTTP command failures preserve intended domain messages, but PostgreSQL SQLSTATE, constraint, driver, host, SQLite, and network diagnostics are replaced with a generic internal-server error. The same rule applies to non-throwing storage status in health/readiness, dashboards, MCP, and Cloud telemetry. SQLite disk-full keeps its actionable message so operators can distinguish and remediate exhausted local storage. ## Data protection - **At rest, SQLite.** Restrict the database to the service user (`chmod 600`) and place it on an encrypted volume. The `-wal` and `-shm` sidecars live in the same directory and require the same handling. - **At rest, PostgreSQL.** Use provider or volume encryption, require TLS with certificate verification, keep the connection URL in a secret manager, and grant the bunqueue role only the target database/schema privileges it needs. - **Backups.** SQLite S3 backups support server-side encryption; scope their IAM credentials to one bucket. PostgreSQL mode does not use this snapshot flow: configure and test normal database backups and point-in-time recovery. - **Job payloads.** Do not place secrets in job data. Store a reference and resolve it inside the worker: ```typescript // Avoid await queue.add('task', { apiKey: 'secret123' }); // Prefer await queue.add('task', { secretRef: 'vault:api-key' }); ``` - **Cloud telemetry.** When the optional bunqueue.io integration is enabled, job payloads and remote commands are both enabled by default. Set `BUNQUEUE_CLOUD_INCLUDE_JOB_DATA=false` for metadata-only telemetry and `BUNQUEUE_CLOUD_REMOTE_COMMANDS=false` for a read-only connection. Specific top-level fields can be redacted with `BUNQUEUE_CLOUD_REDACT_FIELDS`, and outgoing events can be signed with `BUNQUEUE_CLOUD_SIGNING_SECRET`. ## Hardening checklist SQLite-backed server example: ```bash AUTH_TOKENS=$(openssl rand -hex 32) \ TLS_CERT_FILE=/etc/bunqueue/cert.pem \ TLS_KEY_FILE=/etc/bunqueue/key.pem \ HOST=10.0.0.5 \ CORS_ALLOW_ORIGIN=https://dashboard.example.com \ METRICS_AUTH=true \ BUNQUEUE_DATA_PATH=/data/bunq.db \ BUNQUEUE_CLOUD_INCLUDE_JOB_DATA=false \ BUNQUEUE_CLOUD_REMOTE_COMMANDS=false \ bunqueue start ``` 1. Set `AUTH_TOKENS`; never run an exposed instance unauthenticated. 2. Enable TLS, natively or at a proxy; use Unix sockets when everything is on one host. 3. Bind `HOST` to the narrowest interface that still reaches your clients. 4. Leave `CORS_ALLOW_ORIGIN` unset unless a browser client needs the HTTP API; when it does, list the exact origins. 5. If Cloud is enabled, explicitly disable job payload collection and remote commands unless the deployment requires them. 6. Gate `/prometheus` with `METRICS_AUTH=true` where metrics are sensitive. 7. Run as an unprivileged user. For SQLite, `chmod 600` the data file and encrypt its persistent volume; enable S3 backups with server-side encryption where required. 8. For PostgreSQL, inject `BUNQUEUE_POSTGRES_URL` from a secret manager, require verified TLS, use a least-privilege database role, assign a unique `BUNQUEUE_BROKER_ID` to every broker, and rely on database-native HA, backups, and point-in-time recovery instead of the SQLite S3 snapshot flow. 9. Monitor `/health`, watch authentication failures in the logs, and alert on unusual job patterns. ## Supported versions Security fixes are released as patch versions on the current 2.x line. There are no long term support branches: keep the server and the client SDKs (`bunqueue`, `bunqueue-client`) on the latest release. Updates are announced through GitHub Security Advisories and npm advisories. :::tip[Related] - [Native TLS](/guide/tls/), certificates, custom CAs, client options - [Environment Variables](/guide/env-vars/), the full configuration reference - [Deployment Guide](/guide/deployment/), Docker, systemd, PM2 - [Server Mode](/guide/server/), running and operating the server ::: --- # Contributing to bunqueue: Development & PR Guide Contribute to bunqueue: dev environment setup, coding standards, testing guidelines, and pull request workflow for the Bun job queue. URL: https://bunqueue.dev/contributing/
project · contributing

Contribute to bunqueue, ship a PR.

Dev environment setup, coding standards, testing guidelines and the pull request workflow. Everything you need to land a change, whatever your experience level.

## Code of Conduct Be respectful and inclusive. We welcome contributors of all backgrounds and experience levels. ## Getting Started ### Prerequisites - [Bun](https://bun.sh) v1.4.0+ - Git - A GitHub account ### Setup ```bash # Fork the repo on GitHub, then: git clone https://github.com/YOUR_USERNAME/bunqueue.git cd bunqueue bun install ``` ### Running Tests There are three suites. All three must pass before any change lands: ```bash # Unit tests (four isolated file workers) bun test --parallel=4 # TCP integration tests (~50 suites, spawns a real server) bun scripts/tcp/run-all-tests.ts # Embedded integration tests (~35 suites) bun scripts/embedded/run-all-tests.ts ``` Other useful invocations: ```bash # Run a specific test file bun test test/queueManager.test.ts # Run the generated real-broker state model bun run test:model # Reproduce or deepen a model campaign BUNQUEUE_MODEL_RUNS=500 BUNQUEUE_MODEL_COMMANDS=150 \ BUNQUEUE_MODEL_SEED=-1959189325 bun run test:model # Run the full unit suite with coverage bun test --parallel=4 --coverage ``` Note: `bun test` preloads `test/preload.ts`, which sets `BUNQUEUE_EMBEDDED=1`. The full unit command uses four isolated worker processes; tests inside an individual file remain serial. Tests that need real TCP behavior must opt out with an explicit `embedded: false` and spawn a server. Changes to queue lifecycle, persistence/recovery, scheduling, dependencies, deduplication, leases, limits, TTL, counters, or indexes must run `bun run test:model` during iteration. The model uses real TCP, SQLite, and `SIGKILL`; failures include a seed and minimized command history. Preserve every confirmed engine divergence as a deterministic `test/repro-model-*.test.ts` before fixing it. The model run is required in addition to the final isolated `bun run test:sandbox` gate. ### Code Style We use [Oxlint](https://oxc.rs/docs/guide/usage/linter/quickstart.html) for type-aware linting and [Oxfmt](https://oxc.rs/docs/guide/usage/formatter/quickstart.html) for formatting: ```bash # Lint bun run lint # Format code bun run format # Lint + format verification (what CI / the pre-commit hook run) bun run check:oxc ``` ## Making Changes ### Branch Naming - `feat/description` - New features - `fix/description` - Bug fixes - `docs/description` - Documentation - `refactor/description` - Code refactoring - `test/description` - Test additions ### Commit Messages Follow [Conventional Commits](https://www.conventionalcommits.org/): ``` feat: add stall detection for workers fix: resolve memory leak in event listeners docs: update API reference refactor: simplify batch operations test: add DLQ filtering tests ``` ### Pull Request Process 1. Create a feature branch 2. Make your changes 3. Add/update tests 4. Update documentation 5. Run `bun run test:model` for core queue changes, then the authoritative `bun run test:sandbox` gate and `bun run check:oxc` 6. Push and create a PR ### PR Template ```markdown ## Description Brief description of changes ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation ## Testing How was this tested? ## Checklist - [ ] Tests pass - [ ] Linting passes - [ ] Documentation updated ``` ## Project Structure ``` src/ ├── cli/ # CLI commands ├── client/ # Embedded client SDK ├── domain/ # Core business logic ├── application/ # Use cases ├── infrastructure/ # External services └── shared/ # Utilities ``` ### Key Files - `src/domain/queue/shard.ts` - Queue sharding logic - `src/application/queueManager.ts` - Central coordinator - `src/client/queue/queue.ts` - Client Queue class - `src/client/worker/worker.ts` - Client Worker class ## Architecture Guidelines ### File Size - **Max 300 lines per file** - Split if larger ### Lock Order 1. `jobIndex` 2. `completedJobs` 3. `shards[N]` 4. `processingShards[N]` ### Memory Management - Use bounded collections - Clean up event listeners - Release resources in shutdown ## Testing Guidelines ### Test Structure ```typescript describe('Feature', () => { beforeEach(() => { // Setup }); afterEach(() => { // Cleanup }); it('should do something', () => { // Test }); }); ``` ### What to Test - Happy path - Edge cases - Error handling - Concurrent operations - Generated state transitions and crash/recovery invariants for core queue changes ## Documentation ### Code Comments ```typescript /** Brief description */ function simpleFunction() {} /** * Longer description for complex functions * @param input - Description * @returns Description */ function complexFunction(input: string): Result {} ``` ### README Updates Update README.md for: - New features - Changed APIs - New environment variables ## Release Process Releases are handled by the maintainer. Every release: 1. Bumps the patch version in `package.json` 2. Updates the changelog (`docs/src/content/docs/changelog.md`) 3. Publishes to npm with `bun publish` ## Getting Help - [GitHub Discussions](https://github.com/egeominotti/bunqueue/discussions) - [GitHub Issues](https://github.com/egeominotti/bunqueue/issues) ## Recognition Contributors are listed in: - GitHub contributors page - README.md acknowledgments Thank you for contributing! :::tip[Related] - [Architecture & System Design](/architecture/) - Understand the codebase - [Security Best Practices](/security/) - Security guidelines ::: --- # Model-Based Testing: Queue Invariants Under Crash How bunqueue uses fast-check command models, a real TCP broker, SQLite, shrinking, and SIGKILL recovery to verify queue safety. URL: https://bunqueue.dev/architecture/model-based-testing/ bunqueue's example tests are supplemented by four `fast-check` models. The main asynchronous command model starts the real standalone broker on dynamic ports, uses the public MessagePack TCP protocol, writes a fresh SQLite database, and may terminate the broker with `SIGKILL` before reconnecting. Focused models exercise S3 backup/restore, worker-monitoring aggregates and enterprise telemetry conservation/cardinality. ```bash bun run test:model ``` The default broker campaign runs 150 generated histories of up to 80 commands. The backup campaign adds 50 histories of up to 30 commands, the worker monitoring campaign adds 500 histories of up to 80 actions, and the enterprise telemetry campaign adds 500 backup-state histories plus 1,000 queue-selection cases. `bun test` includes it, so `bun run test:sandbox` executes the same model inside the isolated unit container. ## Focused invariants The backup model combines a real WAL-mode SQLite database with an in-memory S3 contract. Generated histories insert rows, pin the WAL with an old reader, create and restore snapshots, prune retention, introduce stale sidecars, and corrupt payloads. Its invariants require every published backup to have metadata, every restore to equal its captured point in time, old WAL frames never to resurrect data, retention to preserve the newest set, and a failed restore never to alter live state. The monitoring model generates worker register, heartbeat, active, complete, fail and unregister transitions. After each action it reconciles registered workers, active jobs and concurrency slots with a simple map oracle, then checks the exported Prometheus gauges. This specifically protects utilization alerts from aggregate-counter drift. The enterprise telemetry model enforces two additional conservation laws: backup attempts equal successes plus failures plus at most one active attempt, the reported scheduler and last-outcome duration/timestamp/compressed-size match the modeled transition, and exported plus omitted queue labels equals the registered queue count while the emitted subset never exceeds its configured cap. ## Why a state model? Queue bugs rarely live in one operation. They appear in histories such as push → pull → update → fail → retry → crash → recover. The model stores the expected lifecycle in memory, executes each generated command on the real engine, and compares the two after every step. When a property fails, `fast-check` shrinks the history to a smaller counterexample and prints a seed and replay path. ## Safety invariants The oracle checks these contracts together: - **Conservation and no loss:** every accepted durable lifecycle is represented by exactly one waiting, prioritized, waiting-children, delayed, active, completed, or failed/DLQ job, unless an explicit remove operation retired it. - **No resurrection:** a terminal generation is never delivered again. Reusing a terminal custom ID creates a strictly newer generation; retrying DLQ or completed work is an explicit transition. - **Exclusive delivery:** two real TCP clients cannot hold the same job active concurrently. At-least-once recovery permits later redelivery, not concurrent ownership. - **Bounded recovery:** `attempts <= maxAttempts` and `stallCount <= maxStalls`; exhausted jobs enter DLQ once. - **Legal transitions:** delivery, retry, terminal completion/failure, promotion, delay, removal, and crash recovery follow the declared graph. - **Ordering:** priority first, FIFO or LIFO tie-break as configured, no delayed or TTL-expired early delivery, one active job per FIFO group, and no parent before its dependencies. - **Failure policies:** fail-parent, ignore, continue, and remove-dependency modes produce their declared parent transition. - **Resources:** custom IDs and unique keys remain exclusive; concurrency and rate tokens admit no extra job or leak when rate acquisition rejects after a concurrency acquisition; expired jobs leave memory, counters, index, write buffer, and SQLite exactly once. - **Internal coherence:** API counts, priority counts, `ShardCounters`, processing maps, dependency maps, locks, `jobIndex`, SQLite `jobs`, and SQLite DLQ agree with the same modeled state. - **Crash idempotence:** an active job becomes a bounded retry or one DLQ entry; a second restart with no intervening operation does not increment or duplicate anything. Failed jobs live in DLQ, so conservation counts that lifecycle once rather than adding a separate duplicate "failed plus DLQ" bucket. ## Commands and observability Generated commands include single and batch push/pull/ack, failure and retry, payload/progress/priority/delay changes, queue and DLQ controls, pause, concurrency and rate limits, heartbeats, flows, custom-ID reuse, invalid token operations, actual process crashes, and isolated contracts for FIFO/LIFO, groups, unique keys, dependencies, delay and TTL. After each command, the test checks: 1. `GetState`, `GetJob`, aggregate counts, and priority counts. 2. Payload generation, priority, attempts, stall count, and live lease tokens. 3. Exact `jobs` and `dlq` rows plus persisted queue controls. 4. `/stats` internal collection telemetry for counters, indexes, processing, dependency, completion, and lock cardinality. ## The 71-invariant register The production checklist contains 71 invariants across 19 categories. The main `fc.commands` lifecycle model owns core safety, ordering, limits, counters, crash recovery, dependencies, DLQ, and queue controls. Focused suites own the contracts that need different clocks or failure injection: cron, worker timeouts and fencing, protocol ambiguity, MessagePack roundtrips, migrations, SQLite integrity, WAL checkpoints, and clean-restart equivalence. The focused CLI category adds generated argv/flag properties, exact command-to-MessagePack fixtures, mutation-free read/error snapshots, API parity, disconnect cancellation, and a real-executable matrix for every command. This distinction matters: a green lifecycle model is not presented as proof of cron or migration behavior. Every checklist item must point to an executable assertion in either the generated model or its named specialist suite. The complete category-to-suite register is maintained in the internal [`model-based-testing.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/features/model-based-testing.md). ## Replaying a failure Use the seed printed by `fast-check` and increase the campaign when needed: ```bash BUNQUEUE_MODEL_RUNS=500 \ BUNQUEUE_MODEL_COMMANDS=150 \ BUNQUEUE_MODEL_SEED=424242 \ bun run test:model ``` First decide whether the model or engine is wrong. A confirmed engine bug must be preserved as a deterministic `test/repro-model-*.test.ts` regression that fails before the fix. This process has already exposed payload and priority updates lost on restart, reset stall budgets, ignored attempt bounds, duplicate DLQ recovery risk, TTL rows left behind in SQLite, dependency-gated jobs left observable after queue obliteration, dangling indexes after DLQ purge, and a stale DLQ generation after terminal custom-ID reuse. It also exposed live lease and client-ownership entries retained after moving active work to delayed. The subsequent full sandbox gate found a tenth defect: low-level jobs created before `stallCount` existed violated the new SQLite `NOT NULL` boundary. Persistence now normalizes an omitted value to zero without weakening the schema. An expanded progress/persistence oracle later found an eleventh defect: `MoveToWait` completed its in-memory transition but left the SQLite row `active`. Restart recovery therefore treated a manual requeue as interrupted work. The transition now persists `state`, `run_at`, and `started_at` through the same `updateRunAt` boundary used by delayed moves. For the complete engineering procedure, see the repository's [`docs/features/model-based-testing.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/features/model-based-testing.md) and [`docs/testing.md`](https://github.com/egeominotti/bunqueue/blob/main/docs/testing.md). --- # Cron Recipes: Intervals, Timezones, Chained Repeats Practical bunqueue scheduling patterns: fixed millisecond intervals, cron patterns evaluated in a specific IANA timezone, and jobs that repeat after each completion. URL: https://bunqueue.dev/guide/cron/recipes/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · cron jobs

The schedules you actually need.

Three shapes cover almost every recurring job: a fixed interval, a wall-clock time in a real timezone, and a job that re-arms itself only once the previous run finished.

## Common Tasks ### Run every N milliseconds Use `every` instead of a cron pattern when you want fixed-rate scheduled slots. The next slot is anchored to the previous scheduled time, not to completion of the generated job. With the default `preventOverlap: true`, a still-active previous generation blocks another one from being enqueued: ```typescript await queue.upsertJobScheduler( 'heartbeat', { every: 60000, // every minute limit: 100, // optional: stop after 100 runs }, { data: { check: 'health' }, } ); ``` ```typescript await queue.upsertJobScheduler( 'heartbeat', { every: 60000, // every minute limit: 100, // optional: stop after 100 runs }, { data: { check: 'health' }, } ); ``` ```python queue.upsert_job_scheduler("heartbeat", {"every": 60000, # every minute "limit": 100}, # optional: stop after 100 runs {"data": {"check": "health"}}) ``` ```php $queue->upsertJobScheduler('heartbeat', [ 'every' => 60000, // every minute 'limit' => 100, // optional: stop after 100 runs ], [ 'data' => ['check' => 'health'], ]); ``` ```go err := queue.UpsertJobScheduler("heartbeat", bunqueue.SchedulerRepeat{ EveryMs: 60000, // every minute Limit: 100, // optional: stop after 100 runs }, bunqueue.SchedulerTemplate{ Data: map[string]any{"check": "health"}, }) ``` ```rust queue.upsert_job_scheduler( "heartbeat", SchedulerRepeat { every_ms: Some(60_000), // every minute limit: Some(100), // optional: stop after 100 runs ..Default::default() }, SchedulerTemplate { data: Value::Map(vec![(Value::from("check"), Value::from("health"))]), ..Default::default() }, )?; ``` ```elixir # every minute; optional limit: stop after 100 runs :ok = Bunqueue.Queue.upsert_scheduler(queue, "heartbeat", %{every: 60_000, limit: 100}, %{data: %{check: "health"}} ) ``` CLI equivalent: `bunqueue cron add heartbeat -q system -d '{"check":"health"}' -e 60000`. ### Schedule in a specific timezone Pass an IANA timezone (like `Europe/Rome` or `America/New_York`) and the pattern is evaluated in that timezone, daylight saving included: ```typescript // 6 PM New York time, weekdays only await queue.upsertJobScheduler( 'end-of-day', { pattern: '0 18 * * 1-5', timezone: 'America/New_York', }, { name: 'end-of-day', data: { type: 'summary' }, } ); ``` ```typescript // 6 PM New York time, weekdays only await queue.upsertJobScheduler( 'end-of-day', { pattern: '0 18 * * 1-5', tz: 'America/New_York', }, { name: 'end-of-day', data: { type: 'summary' }, } ); ``` ```python # 6 PM New York time, weekdays only queue.upsert_job_scheduler("end-of-day", {"pattern": "0 18 * * 1-5", "tz": "America/New_York"}, {"name": "end-of-day", "data": {"type": "summary"}}) ``` ```php // 6 PM New York time, weekdays only $queue->upsertJobScheduler('end-of-day', [ 'pattern' => '0 18 * * 1-5', 'tz' => 'America/New_York', ], [ 'name' => 'end-of-day', 'data' => ['type' => 'summary'], ]); ``` ```go // 6 PM New York time, weekdays only err := queue.UpsertJobScheduler("end-of-day", bunqueue.SchedulerRepeat{ Pattern: "0 18 * * 1-5", Timezone: "America/New_York", }, bunqueue.SchedulerTemplate{ Name: "end-of-day", Data: map[string]any{"type": "summary"}, }) ``` ```rust // 6 PM New York time, weekdays only queue.upsert_job_scheduler( "end-of-day", SchedulerRepeat { pattern: Some("0 18 * * 1-5".into()), timezone: Some("America/New_York".into()), ..Default::default() }, SchedulerTemplate { name: Some("end-of-day".into()), data: Value::Map(vec![(Value::from("type"), Value::from("summary"))]), ..Default::default() }, )?; ``` ```elixir # 6 PM New York time, weekdays only :ok = Bunqueue.Queue.upsert_scheduler(queue, "end-of-day", %{pattern: "0 18 * * 1-5", timezone: "America/New_York"}, %{name: "end-of-day", data: %{type: "summary"}} ) ``` From the CLI, pass `--timezone` (`-z`): ```bash bunqueue cron add daily-report -q reports -d '{"type":"daily"}' \ -s "0 9 * * *" -z Europe/Rome ``` ### Repeat a job after each completion For simple repetition tied to job completion, adding a job with the `repeat` option also works: the job re-enqueues itself `every` milliseconds after each successful completion. Here `limit` is the number of successors, so total executions are the initial job plus at most `limit` repeats. Failed terminal jobs do not create a successor. ```typescript await queue.add('sync', { source: 'crm' }, { repeat: { every: 30000, limit: 10 } }); ``` ```typescript await queue.add('sync', { source: 'crm' }, { repeat: { every: 30000, limit: 10 } }); ``` ```python queue.add("sync", {"source": "crm"}, repeat={"every": 30000, "limit": 10}) ``` ```php $queue->add('sync', ['source' => 'crm'], ['repeat' => ['every' => 30000, 'limit' => 10]]); ``` ```go queue.Add("sync", map[string]any{"source": "crm"}, bunqueue.JobOptions{"repeat": map[string]any{"every": 30000, "limit": 10}}) ``` ```rust queue.add("sync", Value::Map(vec![(Value::from("source"), Value::from("crm"))]), JobOptions { repeat: Some(Value::Map(vec![ (Value::from("every"), Value::from(30000)), (Value::from("limit"), Value::from(10)), ])), ..Default::default() })?; ``` ```elixir {:ok, _job} = Bunqueue.Queue.add(queue, "sync", %{source: "crm"}, repeat: %{every: 30_000, limit: 10} ) ``` `repeat.pattern` on `queue.add` uses the same cron parser as named schedulers. The directly added generation runs first; every successful completion creates the next generation at the calculated cron deadline. `tz`, `startDate`, `endDate`, `offset`, `limit`, and `immediately` stay attached to the chain, and the chain survives a durable SQLite or PostgreSQL broker restart. An `offset` shifts each cron tick without skipping a tick whose shifted deadline is still in the future. Negative offsets advance to the next future shifted tick instead of creating a zero-delay loop. For interval repeats, the offset sets the first successor phase and later generations continue on `every` cadence; `immediately` applies only to the directly added generation. ## Where to go next | | | | --------------------------------------------------------- | -------------------------------------------------- | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules on the Queue object | | [Cron Jobs in Bun](/guide/cron/) | Your first schedule, in every SDK and from the CLI | | [Cron Reference](/guide/cron/reference/) | Expression syntax, every scheduler option, MCP | --- # Cron Reference: Expressions, Options, MCP Reference for bunqueue scheduling: the five-field cron syntax and its shortcuts, every repeat option with defaults and per-SDK naming, and cron control from AI agents. URL: https://bunqueue.dev/guide/cron/reference/
guide · cron jobs

Every field, every option.

The expression syntax with its shortcuts, the full repeat option table with defaults and the naming each SDK uses, plus managing schedules through an AI agent.

## Cron Expression Cheat Sheet Five fields, left to right: minute (0-59), hour (0-23), day of month (1-31), month (1-12), day of week (0-7; both 0 and 7 are Sunday). | Expression | Meaning | | -------------- | ------------------------ | | `0 9 * * *` | Every day at 9:00 AM | | `*/15 * * * *` | Every 15 minutes | | `0 0 * * MON` | Every Monday at midnight | | `0 0 1 * *` | First day of every month | Shortcuts (`@daily`, `@hourly`, `@weekly`, `@monthly`, `@yearly`, `@midnight`) and a six-field form with a leading seconds field are also accepted. The seconds field supports values `0-59`, `*`, lists, ranges and steps; for example, `0,30 * * * * *` runs twice per minute. The five calendar fields are evaluated by Bun's native cron parser. Seven-field expressions with a year and the non-POSIX `L`, `W`, `#`, `+`, and `?` modifiers are not supported. ### Time zones and daylight-saving transitions The five calendar fields follow Bun 1.4's native cron semantics in the selected IANA timezone. The leading-seconds adapter keeps the same calendar decision and then selects the requested second within that minute: - During spring-forward, a fixed time inside the missing hour moves forward by the DST gap (`02:30` runs at `03:30`). For a multi-minute pattern entirely inside the gap, only its first missing match fires after the jump. - During fall-back, a fixed time inside the repeated hour fires once, at the first occurrence. A pattern whose minute or hour field is `*` traverses both occurrences, once per matching real-time minute. These rules intentionally match Bun and Linux cron behavior. They differ from Croner for some wildcard patterns in a repeated fall-back hour. ## Scheduler Options Options on the repeat object of `upsertJobScheduler`: | Option | Default | Description | | --------------------- | ---------------------------------------- | ----------------------------------------------------------------------- | | `pattern` | - | Cron expression | | `every` | - | Positive safe-integer interval in ms (alternative to `pattern`) | | `timezone` | `UTC` (embedded) / server timezone (TCP) | IANA timezone for `pattern` evaluation | | `limit` | unlimited | Max executions, then the scheduler is removed | | `immediately` | `false` | Fire once right away on first creation | | `skipIfNoWorker` | `false` | Skip a run when no worker is registered for the queue | | `preventOverlap` | `true` | Skip a run while the previous job is still active | | `skipMissedOnRestart` | `true` | On server restart, recompute the next run instead of firing missed runs | ### SDK naming and availability | Semantic option | Bun | Node.js / Deno | Python | PHP | Go | Rust | Elixir | | ------------------- | --------------------- | --------------------- | ------------------------ | --------------------- | ------------------------------- | ------------------- | --------------------- | | cron pattern | `pattern` | `pattern` | `pattern` | `pattern` | `Pattern` | `pattern` | `pattern` | | interval | `every` | `every` | `every` | `every` | `EveryMs` | `every_ms` | `every` | | timezone | `timezone` | `tz` | `tz` | `tz` | `Timezone` | `timezone` | `tz` or `timezone` | | skip with no worker | `skipIfNoWorker` | `skipIfNoWorker` | `skip_if_no_worker` | `skipIfNoWorker` | `SkipIfNoWorker` | `skip_if_no_worker` | `skipIfNoWorker` | | missed-run policy | `skipMissedOnRestart` | `skipMissedOnRestart` | `skip_missed_on_restart` | `skipMissedOnRestart` | `SkipMissedOnRestart` (`*bool`) | server default only | `skipMissedOnRestart` | | overlap policy | `preventOverlap` | `preventOverlap` | `prevent_overlap` | `preventOverlap` | `PreventOverlap` (`*bool`) | server default only | `preventOverlap` | Rust's scheduler type currently omits `skipMissedOnRestart` and `preventOverlap`; both therefore use the server default `true`. Go uses pointer booleans for those two fields so explicit `false` is distinct from omission. `addCron` and `every` convenience helpers exist in TypeScript and Python only; PHP, Go, Rust, and Elixir use the scheduler API directly. At least one timing field is required. If both `pattern` and a valid `every` value are supplied, `pattern` takes precedence for backward compatibility. The server rejects non-numeric, non-finite, non-integer, unsafe, zero, and negative intervals before changing the existing scheduler definition. :::note[Fixed-rate and global identity] Scheduler `every` advances from its previous scheduled slot. It is not a delay after job completion. Scheduler IDs are also global to the broker, so prefix them when multiple applications or queues share one server. ::: ## AI Agents (MCP) AI agents can manage cron jobs in natural language ("create a cron that cleans old sessions every hour") through the [MCP Server](/guide/mcp/): ```bash bun add bunqueue @modelcontextprotocol/sdk claude mcp add bunqueue -- bunx --package=bunqueue bunqueue-mcp ``` :::tip[Related Guides] - [Queue API](/guide/queue/) - Job options for cron-created jobs - [CLI Commands](/guide/cli/) - Manage cron jobs via CLI - [MCP Server](/guide/mcp/) - AI agent integration ::: ## Where to go next | | | | --------------------------------------------------------- | --------------------------------------------------- | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules on the Queue object | | [Cron Jobs in Bun](/guide/cron/) | Your first schedule, in every SDK and from the CLI | | [Cron Recipes](/guide/cron/recipes/) | Fixed intervals, timezones, repeat-after-completion | --- # Automatic DLQ Retry How bunqueue automatically redelivers DLQ jobs with a bounded exponential policy that survives SQLite or PostgreSQL broker restarts. URL: https://bunqueue.dev/guide/dlq/auto-retry/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · dead letter queue

Retries that happen without you.

Some failures are transient. Auto-retry puts a due DLQ entry back into its queue, preserves its failure history, and stops after the configured retry budget.

## Automatic Retry With `autoRetry` enabled, a new DLQ entry receives a `nextRetryAt` equal to its entry time plus `autoRetryInterval`. The broker's DLQ maintenance task checks due entries every 60 seconds by default, removes each due entry from the DLQ, advances its automatic retry counter, resets the job's normal attempt and stall counters, and re-queues it. SQLite-backed queues perform the DLQ deletion and waiting-job insertion atomically; PostgreSQL performs the corresponding generation transition in one transaction under the shared dependency lock plan. ```typescript queue.setDlqConfig({ autoRetry: true, autoRetryInterval: 60000, // base delay: 1 minute maxAutoRetries: 3, }); // In TCP mode, prefer await queue.setDlqConfigAsync(...) to confirm the write. ``` ```typescript await queue.setDlqConfig({ autoRetry: true, autoRetryInterval: 60000, // base delay: 1 minute maxAutoRetries: 3, }); ``` ```python # Config keys are the wire names (camelCase) queue.set_dlq_config({ "autoRetry": True, "autoRetryInterval": 60000, # base delay: 1 minute "maxAutoRetries": 3, }) ``` The PHP SDK has no config helper yet. Set the server-side policy through HTTP: ```bash curl -X PUT http://localhost:6790/queues/emails/dlq-config \ -H 'content-type: application/json' \ -d '{"autoRetry":true,"autoRetryInterval":60000,"maxAutoRetries":3}' ``` The Go SDK has no config helper yet. Set the server-side policy through HTTP: ```bash curl -X PUT http://localhost:6790/queues/emails/dlq-config \ -H 'content-type: application/json' \ -d '{"autoRetry":true,"autoRetryInterval":60000,"maxAutoRetries":3}' ``` The Rust SDK has no config helper yet. Set the server-side policy through HTTP: ```bash curl -X PUT http://localhost:6790/queues/emails/dlq-config \ -H 'content-type: application/json' \ -d '{"autoRetry":true,"autoRetryInterval":60000,"maxAutoRetries":3}' ``` The Elixir SDK has no config helper yet. Set the server-side policy through HTTP: ```bash curl -X PUT http://localhost:6790/queues/emails/dlq-config \ -H 'content-type: application/json' \ -d '{"autoRetry":true,"autoRetryInterval":60000,"maxAutoRetries":3}' ``` The config lives server-side per queue, so setting it from any client applies to jobs produced and consumed in every language. The policy, retry counter, complete failure history, original entry/expiry times, and next retry time all survive a SQLite or PostgreSQL broker restart. The first retry becomes due after the base interval. Each dispatch increments `retryCount`; if the redelivered job fails again, its next delay follows `autoRetryInterval * 2^(retryCount - 1)`. Once `retryCount` reaches `maxAutoRetries`, `nextRetryAt` becomes `null` and automatic redelivery stops. :::note[Maintenance cadence and manual retries] A due retry may start up to one maintenance interval after `nextRetryAt`; the default maintenance interval is 60 seconds. Calling a manual `retryDlq` API is an operator-directed new generation: it clears the automatic retry chain and normal attempt/stall counters before re-queuing the job. ::: ## Where to go next | | | | --------------------------------------------------------- | --------------------------------------------------- | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | The same operations from an existing Queue instance | | [Dead Letter Queue](/guide/dlq/) | What the DLQ is, and a first look at what failed | | [DLQ Operations](/guide/dlq/operations/) | Filter, retry selectively, check health, purge | | [DLQ Configuration](/guide/dlq/configuration/) | autoRetry, maxAge, maxEntries and the defaults | | [DLQ Reference](/guide/dlq/reference/) | Failure reasons, entry shape, every DLQ method | --- # DLQ Configuration: Size, Age and Retry Policy Every setDlqConfig option for the bunqueue Dead Letter Queue: automatic retry, base interval, retry ceiling, entry expiry and the per-queue entry cap, with defaults. URL: https://bunqueue.dev/guide/dlq/configuration/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · dead letter queue

How long dead jobs stick around.

A DLQ that grows forever is just a leak with extra steps. These options bound it by age and by count, and decide whether entries get another chance before they expire.

## Configuration ```typescript queue.setDlqConfig({ autoRetry: true, autoRetryInterval: 3600000, maxAutoRetries: 3, maxAge: 604800000, // purge entries after 7 days (null = never) maxEntries: 10000, }); ``` ```typescript await queue.setDlqConfig({ autoRetry: true, autoRetryInterval: 3600000, maxAutoRetries: 3, maxAge: 604800000, // purge entries after 7 days (null = never) maxEntries: 10000, }); ``` ```python # Config keys are the wire names (camelCase) queue.set_dlq_config({ "autoRetry": True, "autoRetryInterval": 3600000, "maxAutoRetries": 3, "maxAge": 604800000, # purge entries after 7 days (None = never) "maxEntries": 10000, }) ``` ```php $queue->connection->call([ 'cmd' => 'SetDlqConfig', 'queue' => $queue->name, 'config' => [ 'autoRetry' => true, 'autoRetryInterval' => 3600000, 'maxAutoRetries' => 3, 'maxAge' => 604800000, 'maxEntries' => 10000, ], ]); ``` ```go _, err := queue.Connection.Call(map[string]any{ "cmd": "SetDlqConfig", "queue": queue.Name, "config": map[string]any{ "autoRetry": true, "autoRetryInterval": 3600000, "maxAutoRetries": 3, "maxAge": 604800000, "maxEntries": 10000, }, }) if err != nil { log.Fatal(err) } ``` ```rust use bunqueue_client::{Connection, ConnectionOptions, Value}; let connection = Connection::new(ConnectionOptions::default()); connection.call(vec![ (Value::from("cmd"), Value::from("SetDlqConfig")), (Value::from("queue"), Value::from("emails")), (Value::from("config"), Value::Map(vec![ (Value::from("autoRetry"), Value::from(true)), (Value::from("autoRetryInterval"), Value::from(3_600_000)), (Value::from("maxAutoRetries"), Value::from(3)), (Value::from("maxAge"), Value::from(604_800_000)), (Value::from("maxEntries"), Value::from(10_000)), ])), ])?; ``` ```elixir {:ok, _response} = Bunqueue.Queue.call(queue, %{ "cmd" => "SetDlqConfig", "queue" => queue.name, "config" => %{ "autoRetry" => true, "autoRetryInterval" => 3_600_000, "maxAutoRetries" => 3, "maxAge" => 604_800_000, "maxEntries" => 10_000 } }) ``` *The DLQ policy is server-side state per queue: set it once from any client and it governs jobs failed by workers in every language. A dedicated helper ships in the Bun package, the TypeScript SDK and the Python SDK; the other tabs issue the same `SetDlqConfig` command through each SDK's public TCP connection.* :::note[Confirming the write in TCP mode] In the Bun client `setDlqConfig()` is fire-and-forget over TCP: it updates the local cache and sends the command without waiting, so a transport failure is not reported to you. Use `await queue.setDlqConfigAsync(config)` when you need the call to resolve only once the server has applied the policy. In embedded mode the two are equivalent. ::: | Option | Default | Description | |--------|---------|-------------| | `autoRetry` | `false` | Enable automatic retry | | `autoRetryInterval` | `3600000` | Base delay between auto-retries (1 hour) | | `maxAutoRetries` | `3` | Maximum auto-retry attempts | | `maxAge` | `604800000` | Auto-purge age (7 days, `null` = never) | | `maxEntries` | `10000` | Maximum DLQ entries per queue | ## Where to go next | | | |---|---| | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | The same operations from an existing Queue instance | | [Dead Letter Queue](/guide/dlq/) | What the DLQ is, and a first look at what failed | | [DLQ Operations](/guide/dlq/operations/) | Filter, retry selectively, check health, purge | | [Automatic DLQ Retry with Backoff](/guide/dlq/auto-retry/) | Let bunqueue re-queue dead entries on a backoff | | [DLQ Reference](/guide/dlq/reference/) | Failure reasons, entry shape, every DLQ method | --- # DLQ Operations: Filter, Retry, Remove, Purge Everyday work against the bunqueue Dead Letter Queue: filter entries by reason or age, retry a subset, permanently remove one entry, read health counters and purge what you do not need. URL: https://bunqueue.dev/guide/dlq/operations/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · dead letter queue

Read it, retry it, clear it.

The four things you actually do with a Dead Letter Queue: narrow it down to the entries you care about, put a subset back, check whether the pile is growing, and empty it when the cause is fixed.

## Common Tasks ### Filter entries ```typescript await queue.getDlqAsync({ reason: 'max_attempts_exceeded' }); // by emitted reason await queue.getDlqAsync({ olderThan: Date.now() - 86400000 }); // older than 24h await queue.getDlqAsync({ newerThan: Date.now() - 3600000 }); // last hour await queue.getDlqAsync({ retriable: true }); // auto-retry is due now await queue.getDlqAsync({ limit: 10, offset: 20 }); // pagination ``` The SDK's `getDlq()` returns jobs only. Use the HTTP endpoint for full entry metadata, then filter the returned page in the client: ```typescript const response = await fetch('http://localhost:6790/queues/emails/dlq?limit=100&offset=0'); const { entries, total } = await response.json(); const stalled = entries.filter((entry) => entry.reason === 'stalled'); ``` The SDK's `get_dlq()` returns jobs only. The standard library can read full entries from HTTP: ```python import json from urllib.request import urlopen with urlopen("http://localhost:6790/queues/emails/dlq?limit=100&offset=0") as response: payload = json.load(response) stalled = [entry for entry in payload["entries"] if entry["reason"] == "stalled"] ``` The SDK's `getDlq()` returns jobs only. Read full entries through HTTP: ```php $json = file_get_contents('http://localhost:6790/queues/emails/dlq?limit=100&offset=0'); $payload = json_decode($json, true, flags: JSON_THROW_ON_ERROR); $stalled = array_filter($payload['entries'], fn ($entry) => $entry['reason'] === 'stalled'); ``` The SDK's `GetDlq()` returns jobs only. Read full entries through HTTP with the standard library: ```go response, err := http.Get("http://localhost:6790/queues/emails/dlq?limit=100&offset=0") if err != nil { return err } defer response.Body.Close() var payload struct { Entries []map[string]any `json:"entries"` } err = json.NewDecoder(response.Body).Decode(&payload) ``` The Rust SDK's `get_dlq()` returns jobs only. Use the broker HTTP endpoint (and your application's HTTP/JSON crate) for full entries: ```bash curl 'http://localhost:6790/queues/emails/dlq?limit=100&offset=0' ``` The Elixir SDK's `dlq/2` returns jobs only. Use the broker HTTP endpoint for full entries: ```bash curl 'http://localhost:6790/queues/emails/dlq?limit=100&offset=0' ``` The Bun async API applies `reason`, time, due-retry, expiry, limit, and offset filters server-side in both embedded and TCP modes. The HTTP endpoint currently supports only `limit` and `offset`; external clients must filter that page locally. `retriable: true` means `nextRetryAt` is already due, not merely that the entry has retry budget remaining. ### Retry selectively ```typescript queue.retryDlq(); // retry everything queue.retryDlq('job-123'); // retry one job queue.retryDlqByFilter({ reason: 'stalled' }); // TCP is fire-and-forget const filtered = await queue.retryDlqByFilterAsync({ reason: 'stalled' }); // authoritative count const n = await queue.retryDlqAsync(); // retry and get the count (TCP too) ``` ```typescript await queue.retryDlq(); // retry everything await queue.retryDlq('job-123'); // retry one job await queue.retryJobs({ count: 100 }); // retry only the first 100 entries ``` ```python queue.retry_dlq() # retry everything queue.retry_dlq("job-123") # retry one job queue.retry_dlq(count=100) # retry only the first 100 entries ``` ```php $queue->retryDlq(); // retry everything $queue->retryDlq('job-123'); // retry one job $queue->retryDlq(null, 100); // retry only the first 100 entries ``` ```go n, err := queue.RetryDlq("", 0) // retry everything n, err = queue.RetryDlq("job-123", 0) // retry one job n, err = queue.RetryDlq("", 100) // retry only the first 100 entries ``` ```rust queue.retry_dlq(None, None)?; // retry everything queue.retry_dlq(Some("job-123"), None)?; // retry one job queue.retry_dlq(None, Some(100))?; // retry only the first 100 entries ``` ```elixir {:ok, n} = Bunqueue.Queue.retry_dlq(queue) # retry everything {:ok, _} = Bunqueue.Queue.retry_dlq(queue, "job-123") # retry one job {:ok, _} = Bunqueue.Queue.retry_dlq(queue, nil, 100) # retry only the first 100 ``` ### Check DLQ health ```typescript const stats = await queue.getDlqStatsAsync(); console.log(stats.total); // total entries console.log(stats.byReason); // { max_attempts_exceeded: 5, stalled: 2, ... } console.log(stats.pendingRetry); // entries whose auto-retry time is due ``` A simple alert loop: ```typescript setInterval(async () => { const stats = await queue.getDlqStatsAsync(); if (stats.total > 100) alertOps('High DLQ count', stats); }, 30000); ``` ```typescript const { stats } = await (await fetch( 'http://localhost:6790/queues/emails/dlq/stats' )).json(); ``` ```python with urlopen("http://localhost:6790/queues/emails/dlq/stats") as response: stats = json.load(response)["stats"] ``` ```php $payload = json_decode( file_get_contents('http://localhost:6790/queues/emails/dlq/stats'), true, flags: JSON_THROW_ON_ERROR, ); $stats = $payload['stats']; ``` ```go response, err := http.Get("http://localhost:6790/queues/emails/dlq/stats") // Decode response.Body and read the top-level "stats" object. ``` ```bash curl 'http://localhost:6790/queues/emails/dlq/stats' ``` ```bash curl 'http://localhost:6790/queues/emails/dlq/stats' ``` Use synchronous `getDlqStats()` for a Bun embedded snapshot and `getDlqStatsAsync()` for an authoritative Bun result in either runtime. The other SDKs do not expose a stats helper yet; the HTTP result is authoritative. ### Permanently remove one failed job ```typescript const removed = await queue.removeDlqJob('job-123'); // true: the selected DLQ entry was deleted // false: it was already absent ``` `removeDlqJobAsync(id)` is an explicit alias with the same `Promise` contract. The operation does not retry the job. It removes the durable entry and terminal auxiliary state before resolving, including any recovered duplicate rows for the same queue and job ID. Broker and persistence errors reject the Promise; only a successful miss resolves `false`. ### Purge ```typescript const purged = queue.purgeDlq(); // permanently deletes all entries const n = await queue.purgeDlqAsync(); // same, but waits and returns the count (TCP too) ``` ```typescript const purged = await queue.purgeDlq(); // permanently deletes all entries, returns the count ``` ```python purged = queue.purge_dlq() # permanently deletes all entries, returns the count ``` ```php $purged = $queue->purgeDlq(); // permanently deletes all entries, returns the count ``` ```go purged, err := queue.PurgeDlq() // permanently deletes all entries, returns the count ``` ```rust let purged = queue.purge_dlq()?; // permanently deletes all entries, returns the count ``` ```elixir {:ok, purged} = Bunqueue.Queue.purge_dlq(queue) # deletes all entries, returns the count ``` ## Where to go next | | | |---|---| | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | The same operations from an existing Queue instance | | [Dead Letter Queue](/guide/dlq/) | What the DLQ is, and a first look at what failed | | [Automatic DLQ Retry with Backoff](/guide/dlq/auto-retry/) | Let bunqueue re-queue dead entries on a backoff | | [DLQ Configuration](/guide/dlq/configuration/) | autoRetry, maxAge, maxEntries and the defaults | | [DLQ Reference](/guide/dlq/reference/) | Failure reasons, entry shape, every DLQ method | --- # DLQ Reference: Reasons, Entry Shape, Methods Reference for bunqueue Dead Letter Queue reasons, reserved categories, the full entry structure with attempt history, and the complete method surface. URL: https://bunqueue.dev/guide/dlq/reference/
guide · dead letter queue

Every reason, every field.

What each failure reason means, what an entry actually contains once it is written, and the full list of DLQ methods with their embedded and TCP behaviour.

## Reference ### Why jobs end up in the DLQ | Reason | Description | |--------|-------------| | `explicit_fail` | A retryable processor failure in the attempt history | | `max_attempts_exceeded` | A normal processor failure exhausted the job's attempts | | `timeout` | A processing timeout; retained on retry attempts and terminal timeout entries | | `stalled` | Job stopped sending heartbeats (worker likely crashed) | | `ttl_expired` | Reserved category; current waiting-job TTL expiry removes the job instead of creating a DLQ entry | | `worker_lost` | Reserved category; current disconnect/lock recovery is classified as `stalled` | | `unknown` | Fallback for unclassified failures | The entry's `reason` is the terminal cause. Its `attempts` array preserves the cause of every failed attempt, so a timeout followed by a normal processor failure is represented as `[timeout, max_attempts_exceeded]`, while two consecutive timeouts remain `[timeout, timeout]`. ### Entry structure ```typescript interface DlqEntry { job: Job; // The failed job enteredAt: number; // When first moved to DLQ reason: FailureReason; // Why it failed error: string | null; // Error message attempts: AttemptRecord[]; // Full attempt history retryCount: number; // Times retried from DLQ lastRetryAt: number | null; // Last DLQ retry time nextRetryAt: number | null; // Next scheduled auto-retry expiresAt: number | null; // When entry expires } ``` Each `AttemptRecord` carries the attempt number, start and failure timestamps, failure reason, error message, and duration in ms. ### Bun Queue methods | Method | Result | Runtime behavior | |--------|--------|------------------| | `getDlq(filter?)` | `DlqEntry[]` | Synchronous embedded snapshot | | `getDlqAsync(filter?)` | `Promise` | Authoritative embedded or TCP read | | `getDlqStatsAsync()` | `Promise` | Authoritative embedded or TCP statistics | | `retryDlqAsync(id?)` | `Promise` | Re-queues selected entries and returns the count | | `retryDlqByFilterAsync(filter)` | `Promise` | Re-queues matching entries | | `removeDlqJob(id)` | `Promise` | Permanently removes one job; rejects broker errors | | `removeDlqJobAsync(id)` | `Promise` | Explicit alias of `removeDlqJob` | | `purgeDlqAsync()` | `Promise` | Permanently removes every entry and returns the count | Selective removal is idempotent: `false` means the entry did not exist. It is not an error fallback. A failed durable delete rejects and leaves the DLQ entry authoritative. :::tip[Related Guides] - [Stall Detection & Recovery](/guide/stall-detection/) - Stalled jobs are sent to the DLQ - [Worker API](/guide/worker/) - Configure retry behavior - [Monitoring & Prometheus Metrics](/guide/monitoring/) - Alert on DLQ size ::: ## Where to go next | | | |---|---| | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | The same operations from an existing Queue instance | | [Dead Letter Queue](/guide/dlq/) | What the DLQ is, and a first look at what failed | | [DLQ Operations](/guide/dlq/operations/) | Filter, retry selectively, check health, purge | | [Automatic DLQ Retry with Backoff](/guide/dlq/auto-retry/) | Let bunqueue re-queue dead entries on a backoff | | [DLQ Configuration](/guide/dlq/configuration/) | autoRetry, maxAge, maxEntries and the defaults | --- # Flow Failure Handling: When a Child Fails By default a bunqueue parent keeps waiting. Four child options change that: fail the parent, ignore the failure, remove pending siblings or continue on partial results. URL: https://bunqueue.dev/guide/flow/failures/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · flow producer

One child dies. Now what?

The default is to keep waiting, which is rarely what you want. These four options let a failed child fail the parent, be ignored, cancel its siblings, or let the parent run on whatever succeeded.

## When a Child Fails By default a parent just keeps waiting for its remaining children. Four child options change what happens when a child fails terminally (no retries left): | Option | Behavior | |--------|----------| | `failParentOnFailure` | Parent immediately moves to `failed`, even if other children are still running | | `removeDependencyOnFailure` | The failed child is silently dropped from the parent's dependencies; the parent proceeds as if it never existed | | `ignoreDependencyOnFailure` | Like the above, but the failure is recorded; the parent can read it via `job.getIgnoredChildrenFailures()` | | `continueParentOnFailure` | Parent is promoted to run **immediately**; it can inspect failures via `job.getFailedChildrenValues()` and cancel leftover children | Choose at most one policy per child. The selected policy and any unresolved failure record are durable: a broker restart cannot reset the child to default behavior or put an already-released parent back into `waiting-children`. A worked example with `continueParentOnFailure`, useful when the parent should decide how to handle partial failure: ```typescript await flow.add({ name: 'pipeline', queueName: 'main', data: {}, children: [ { name: 'step-a', queueName: 'workers', data: {}, opts: { continueParentOnFailure: true } }, { name: 'step-b', queueName: 'workers', data: {}, opts: { continueParentOnFailure: true } }, { name: 'step-c', queueName: 'workers', data: {} }, ], }); const worker = new Worker('main', async (job) => { const failed = await job.getFailedChildrenValues(); // { 'workers:job-abc': 'Error: step-a failed', ... } if (Object.keys(failed).length > 0) { await job.removeUnprocessedChildren(); // cancel children still waiting return { status: 'partial', failedSteps: failed }; } return { status: 'complete' }; }, { embedded: true }); ``` ```typescript import { FlowProducer, Queue, Worker } from 'bunqueue-client'; const flow = new FlowProducer(); const queue = new Queue('main'); await flow.add({ name: 'pipeline', queueName: 'main', data: {}, children: [ { name: 'step-a', queueName: 'workers', data: {}, opts: { continueParentOnFailure: true } }, { name: 'step-b', queueName: 'workers', data: {}, opts: { continueParentOnFailure: true } }, { name: 'step-c', queueName: 'workers', data: {} }, ], }); const worker = new Worker('main', async (job) => { const failed = await queue.getFailedChildrenValues(job.id); // { 'workers:job-abc': 'Error: step-a failed', ... } if (Object.keys(failed).length > 0) { await queue.removeUnprocessedChildren(job.id); // cancel children still waiting return { status: 'partial', failedSteps: failed }; } return { status: 'complete' }; }); ``` ```python from bunqueue import FlowProducer, Queue, Worker flow = FlowProducer() queue = Queue("main") flow.add({ "name": "pipeline", "queueName": "main", "data": {}, "children": [ {"name": "step-a", "queueName": "workers", "data": {}, "opts": {"continue_parent_on_failure": True}}, {"name": "step-b", "queueName": "workers", "data": {}, "opts": {"continue_parent_on_failure": True}}, {"name": "step-c", "queueName": "workers", "data": {}}, ], }) def process(job): failed = queue.get_failed_children_values(job.id) # {"workers:job-abc": "Error: step-a failed", ...} if failed: queue.remove_unprocessed_children(job.id) # cancel children still waiting return {"status": "partial", "failed_steps": failed} return {"status": "complete"} Worker("main", process).run() ``` The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from PHP like anywhere else; read child outcomes by looking the child IDs up with `getJob($childId)` / `getResult($childId)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Go like anywhere else; read child outcomes by looking the child IDs up with `GetJob(childID)` / `GetResult(childID)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Rust like anywhere else; read child outcomes by looking the child IDs up with `get_job(child_id)` / `get_result(child_id)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Elixir like anywhere else; read child outcomes by looking the child IDs up with `get_job(child_id)` / `get_result(child_id)` on the queue. And with `ignoreDependencyOnFailure`, when the parent should continue with partial data: ```typescript const worker = new Worker('reports', async (job) => { const ignored = await job.getIgnoredChildrenFailures(); // { 'workers:job-abc': 'Error: enrichment API timeout' } return { partial: Object.keys(ignored).length > 0 }; }, { embedded: true }); ``` ```typescript const queue = new Queue('reports'); const worker = new Worker('reports', async (job) => { const ignored = await queue.getIgnoredChildrenFailures(job.id); // { 'workers:job-abc': 'Error: enrichment API timeout' } return { partial: Object.keys(ignored).length > 0 }; }); ``` ```python queue = Queue("reports") def process(job): ignored = queue.get_ignored_children_failures(job.id) # {"workers:job-abc": "Error: enrichment API timeout"} return {"partial": bool(ignored)} Worker("reports", process).run() ``` The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from PHP like anywhere else; read child outcomes by looking the child IDs up with `getJob($childId)` / `getResult($childId)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Go like anywhere else; read child outcomes by looking the child IDs up with `GetJob(childID)` / `GetResult(childID)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Rust like anywhere else; read child outcomes by looking the child IDs up with `get_job(child_id)` / `get_result(child_id)` on the queue. The child-failure queries (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are available in the Bun package and the TypeScript and Python SDKs. The failure options themselves work from Elixir like anywhere else; read child outcomes by looking the child IDs up with `get_job(child_id)` / `get_result(child_id)` on the queue. *All four failure-propagation options are ordinary job options and reach the wire in every SDK. The reading helpers (`getFailedChildrenValues`, `getIgnoredChildrenFailures`, `removeUnprocessedChildren`) are currently available in the Bun package, the TypeScript SDK and the Python SDK (on `Queue`, taking the job id).* ## Where to go next | | | |---|---| | [Flow Producer](/guide/flow/) | Your first parent/child graph, and what is guaranteed | | [Flow Patterns](/guide/flow/patterns/) | Chains, fan-in, trees, reading child results, options | | [Flow Producer Reference](/guide/flow/reference/) | Every producer method, job helper and step field | --- # Flow Patterns: Chains, Fan-In and Trees The graph shapes bunqueue flows support: sequential chains, parallel children merged by a parent, deeper trees, reading child results and per-job options. URL: https://bunqueue.dev/guide/flow/patterns/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · flow producer

Chain it, fan it out, merge it back.

Four shapes cover nearly every pipeline: one after another, many at once into a single parent, a deeper tree, and the plumbing that lets a parent read what its children produced.

## Common Tasks ### Run jobs one after another (chain) `addChain` executes jobs in order: each starts only when the previous one completes. ```typescript // fetch → process → store const { jobIds } = await flow.addChain([ { name: 'fetch', queueName: 'pipeline', data: { url: 'https://api.example.com' } }, { name: 'process', queueName: 'pipeline', data: {} }, { name: 'store', queueName: 'pipeline', data: {} }, ]); ``` ```typescript // fetch → process → store const { jobIds } = await flow.addChain([ { name: 'fetch', queueName: 'pipeline', data: { url: 'https://api.example.com' } }, { name: 'process', queueName: 'pipeline', data: {} }, { name: 'store', queueName: 'pipeline', data: {} }, ]); ``` ```python # fetch -> process -> store job_ids = flow.add_chain([ {"name": "fetch", "queueName": "pipeline", "data": {"url": "https://api.example.com"}}, {"name": "process", "queueName": "pipeline", "data": {}}, {"name": "store", "queueName": "pipeline", "data": {}}, ]) ``` ```php // fetch -> process -> store $jobIds = $flow->addChain([ ['name' => 'fetch', 'queueName' => 'pipeline', 'data' => ['url' => 'https://api.example.com']], ['name' => 'process', 'queueName' => 'pipeline'], ['name' => 'store', 'queueName' => 'pipeline'], ]); ``` ```go // fetch -> process -> store ids, err := flow.AddChain([]bunqueue.ChainStep{ {Name: "fetch", QueueName: "pipeline", Data: map[string]any{"url": "https://api.example.com"}}, {Name: "process", QueueName: "pipeline"}, {Name: "store", QueueName: "pipeline"}, }) ``` ```rust use bunqueue_client::{ChainStep, JobOptions, Value}; // fetch -> process -> store let step = |name: &str| ChainStep { name: name.into(), queue_name: "pipeline".into(), data: Value::Nil, options: JobOptions::default(), }; let ids = flow.add_chain(vec![step("fetch"), step("process"), step("store")])?; ``` ```elixir # fetch -> process -> store {:ok, ids} = Bunqueue.FlowProducer.add_chain(flow, [ %{name: "fetch", queue: "pipeline", data: %{url: "https://api.example.com"}}, %{name: "process", queue: "pipeline"}, %{name: "store", queue: "pipeline"} ]) ``` ### Run jobs in parallel, then merge (fan-in) `addBulkThen` runs a batch concurrently and fires a final job after all of them complete. ```typescript // fetch-api-1 ──┐ // fetch-api-2 ──┼──→ merge-results // fetch-api-3 ──┘ const { parallelIds, finalId } = await flow.addBulkThen( [ { name: 'fetch-api-1', queueName: 'parallel', data: { source: 'api1' } }, { name: 'fetch-api-2', queueName: 'parallel', data: { source: 'api2' } }, { name: 'fetch-api-3', queueName: 'parallel', data: { source: 'api3' } }, ], { name: 'merge-results', queueName: 'parallel', data: {} } ); ``` ```typescript // fetch-api-1 ──┐ // fetch-api-2 ──┼──→ merge-results // fetch-api-3 ──┘ const { parallelIds, finalId } = await flow.addBulkThen( [ { name: 'fetch-api-1', queueName: 'parallel', data: { source: 'api1' } }, { name: 'fetch-api-2', queueName: 'parallel', data: { source: 'api2' } }, { name: 'fetch-api-3', queueName: 'parallel', data: { source: 'api3' } }, ], { name: 'merge-results', queueName: 'parallel', data: {} } ); ``` ```python # fetch-api-1 --+ # fetch-api-2 --+--> merge-results # fetch-api-3 --+ result = flow.add_bulk_then( [ {"name": "fetch-api-1", "queueName": "parallel", "data": {"source": "api1"}}, {"name": "fetch-api-2", "queueName": "parallel", "data": {"source": "api2"}}, {"name": "fetch-api-3", "queueName": "parallel", "data": {"source": "api3"}}, ], {"name": "merge-results", "queueName": "parallel", "data": {}}, ) parallel_ids, final_id = result["parallel_ids"], result["final_id"] ``` `addBulkThen` is available in the Bun package and the TypeScript and Python SDKs. In PHP, build the same shape with `add`: a parent whose `children` are the parallel jobs, so the children complete before the parent runs. `addBulkThen` is available in the Bun package and the TypeScript and Python SDKs. In Go, build the same shape with `add`: a parent whose `children` are the parallel jobs, so the children complete before the parent runs. `addBulkThen` is available in the Bun package and the TypeScript and Python SDKs. In Rust, build the same shape with `add`: a parent whose `children` are the parallel jobs, so the children complete before the parent runs. `addBulkThen` is available in the Bun package and the TypeScript and Python SDKs. In Elixir, build the same shape with `add`: a parent whose `children` are the parallel jobs, so the children complete before the parent runs. *`addBulkThen` is available in the Bun package, the TypeScript SDK and the Python SDK. In PHP, Go, Rust and Elixir build the same shape with `add`: a parent whose `children` are the parallel jobs (children complete first, then the parent runs).* ### Build a tree `addTree` creates a hierarchy where children depend on their parent (the parent runs first, then its children), within the [creation limits](/guide/flow/#creation-guarantees-and-limits): ```typescript const { jobIds } = await flow.addTree({ name: 'root', queueName: 'tree', data: { level: 0 }, children: [ { name: 'branch-1', queueName: 'tree', data: { level: 1 }, children: [ { name: 'leaf-1a', queueName: 'tree', data: { level: 2 } }, { name: 'leaf-1b', queueName: 'tree', data: { level: 2 } }, ], }, { name: 'branch-2', queueName: 'tree', data: { level: 1 } }, ], }); ``` `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In Node.js / Deno, `add` builds the inverse tree, where children complete before their parent. `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In Python, `add` builds the inverse tree, where children complete before their parent. `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In PHP, `add` builds the inverse tree, where children complete before their parent. `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In Go, `add` builds the inverse tree, where children complete before their parent. `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In Rust, `add` builds the inverse tree, where children complete before their parent. `addTree` (parent-first hierarchies) is available in the Bun `bunqueue` package only. In Elixir, `add` builds the inverse tree, where children complete before their parent. *`addTree` (parent-first hierarchies) is available in the Bun package only for now. Every SDK's `add` builds the inverse tree, where children complete before their parent ([Quick Start](/guide/flow/#quick-start)).* ### Read results from earlier jobs In `flow.add()` flows, the parent calls `await job.getChildrenValues()` (shown in the Quick Start). In `addChain` / `addBulkThen` / `addTree` flows, bunqueue injects parent IDs into the job data, and FlowProducer can look up their results in embedded or TCP mode: ```typescript const runtimeOptions = { embedded: false, connection: { host: '127.0.0.1', port: 6789 } }; const flow = new FlowProducer(runtimeOptions); const worker = new Worker('pipeline', async (job) => { if (job.data.__flowParentId) { // chain: one parent const parentResult = await flow.getParentResult(job.data.__flowParentId); } if (job.data.__flowParentIds) { // merge: many parents const results = await flow.getParentResults(job.data.__flowParentIds); } return { processed: true }; }, runtimeOptions); ``` `getParentResult` / `getParentResults` are Bun-package helpers. In Node.js / Deno, read a parent with `getResult(parentId)` on the queue. `getParentResult` / `getParentResults` are Bun-package helpers. In Python, read a parent with `get_result(parent_id)` on the queue. `getParentResult` / `getParentResults` are Bun-package helpers. In PHP, read a parent with `getResult($parentId)` on the queue. `getParentResult` / `getParentResults` are Bun-package helpers. In Go, read a parent with `GetResult(parentID)` on the queue. `getParentResult` / `getParentResults` are Bun-package helpers. In Rust, read a parent with `get_result(parent_id)` on the queue. `getParentResult` / `getParentResults` are Bun-package helpers. In Elixir, read a parent with `get_result(parent_id)` on the queue. *`getParentResult` / `getParentResults` are Bun-package helpers for both embedded and TCP runtimes. Await them in transport-neutral code: embedded keeps the historical synchronous return, while TCP performs `GetResult` round trips. The external SDKs can read a parent with `getResult(parentId)` (`get_result` in Python).* Injected fields: `__flowParentId`, `__flowParentIds`, plus the BullMQ-compatible `__parentId`, `__parentQueue`, and `__childrenIds`. A non-root `addChain` or `addTree` step receives `__flowParentId`, `__parentId`, and `__parentQueue` for its exact predecessor, including the predecessor's real queue in cross-queue flows. Fan-in jobs retain their existing parent/children fields. They are typed via the `FlowJobData` interface, exposed by Worker and Queue reads, persisted in the selected SQLite or PostgreSQL backend, and survive restarts when persistence is configured. Keys beginning with `__` are reserved on flow input. `job.updateData(userData)` preserves the engine-owned topology fields and rejects attempts to forge them; the user payload, including a user `name` key, remains separate from the job's own name. ### Set per-job and per-queue options Each step accepts normal job options via `opts`. With `flow.add()`, you can also set defaults for every job on a given queue: ```typescript await flow.add( { name: 'report', queueName: 'reports', children: [ { name: 'fetch', queueName: 'api', data: {}, opts: { priority: 10 } }, { name: 'render', queueName: 'cpu', data: {} }, ], }, { queuesOptions: { api: { attempts: 5, backoff: 2000 }, // defaults for all 'api' jobs cpu: { timeout: 60000 }, // defaults for all 'cpu' jobs }, } ); ``` ```typescript import { FlowProducer } from 'bunqueue-client'; await flow.add( { name: 'report', queueName: 'reports', children: [ { name: 'fetch', queueName: 'api', data: {}, opts: { priority: 10 } }, { name: 'render', queueName: 'cpu', data: {} }, ], }, { queuesOptions: { api: { attempts: 5, backoff: 2000 }, // defaults for all 'api' jobs cpu: { timeout: 60000 }, // defaults for all 'cpu' jobs }, } ); ``` ```python flow.add( { "name": "report", "queueName": "reports", "children": [ {"name": "fetch", "queueName": "api", "data": {}, "opts": {"priority": 10}}, {"name": "render", "queueName": "cpu", "data": {}}, ], }, { "queues_options": { "api": {"attempts": 5, "backoff": 2000}, # defaults for all 'api' jobs "cpu": {"timeout": 60000}, # defaults for all 'cpu' jobs }, }, ) ``` Per-queue defaults (`queuesOptions`) are supported in the Bun package, the TypeScript SDK and the Python SDK. In PHP, set per-step `opts` on each node instead; that is available everywhere. Per-queue defaults (`queuesOptions`) are supported in the Bun package, the TypeScript SDK and the Python SDK. In Go, set per-step `opts` on each node instead; that is available everywhere. Per-queue defaults (`queuesOptions`) are supported in the Bun package, the TypeScript SDK and the Python SDK. In Rust, set per-step `opts` on each node instead; that is available everywhere. Per-queue defaults (`queuesOptions`) are supported in the Bun package, the TypeScript SDK and the Python SDK. In Elixir, set per-step `opts` on each node instead; that is available everywhere. *Per-queue defaults (`queuesOptions`) are supported in the Bun package, the TypeScript SDK and the Python SDK; in the other SDKs set per-step `opts` on each node instead (available everywhere).* Per-job `opts` override `queuesOptions` defaults. Identity is the exception: set `jobId` (or Python `job_id`) in the individual node's `opts`. It is rejected inside `queuesOptions` / `queues_options`, where one default could otherwise assign the same ID to multiple nodes. Note that `delay` on a chained step sets its earliest run time, but the step still waits for its dependency to complete first. The Bun producer also carries `group: { id, priority?, maxSize? }` from each node into the atomic graph. Group priority uses `0` first and then ascending values. If a `maxSize` capacity check fails, no node in that `add()` or `addBulk()` transaction is admitted. PostgreSQL serializes the capacity check across brokers; SQLite performs it in the same local admission transaction. ### Inspect an existing graph ```typescript const tree = await flow.getFlow({ id: node.job.id, queueName: 'reports', depth: 2, maxChildren: 10, }); if (!tree) console.log('root not found, or queue name did not match'); ``` `depth` and `maxChildren` accept non-negative integers (or `Infinity`); `maxChildren: 0` returns only the requested node. A missing root or queue mismatch returns `null`. A missing descendant, malformed topology, cycle, or real TCP/server error throws instead of returning a misleading partial tree. Each returned descendant must also point back to the parent being traversed; corrupt or cross-linked ownership is rejected. Dependency keys returned by `getDependencies()` use each child's actual queue, so cross-queue keys are `childQueue:childId`. ## Where to go next | | | |---|---| | [Flow Producer](/guide/flow/) | Your first parent/child graph, and what is guaranteed | | [Flow Failure Handling](/guide/flow/failures/) | What a parent does when a child dies for good | | [Flow Producer Reference](/guide/flow/reference/) | Every producer method, job helper and step field | --- # Flow Producer Reference: Methods and Shapes Reference for bunqueue flows: every FlowProducer method, the job helpers available inside a worker processor, and the exact shape of a flow step. URL: https://bunqueue.dev/guide/flow/reference/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · flow producer

Every method, spelled out.

The producer surface, what a processor can ask about its children mid-run, and the full field list of a step so you can build graphs programmatically.

## Reference ### FlowProducer methods | Method | Description | |--------|-------------| | `add(flow, opts?)` | BullMQ v5: tree where children complete before the parent (atomic) | | `addBulk(flows[])` | BullMQ v5: add multiple flow trees (atomic, all-or-nothing) | | `getFlow({ id, queueName, depth?, maxChildren? })` | Retrieve a flow tree by root job ID | | `addChain(steps[])` | Sequential execution: A → B → C | | `addBulkThen(parallel[], final)` | Parallel then converge: [A, B, C] → D | | `addTree(root)` | Hierarchical tree with nested children | | `getParentResult(parentId)` | Exact result of one completed parent, embedded or TCP | | `getParentResults(parentIds[])` | Ordered results for completed parents, embedded or TCP | | `close()` / `disconnect()` | Close the connection pool | | `waitUntilReady()` | Wait until the FlowProducer is connected | The table describes the Bun package. SDK availability: `add` and `addChain` exist in all six SDKs; `getFlow` in TypeScript, Python, PHP and Go; `addBulk` and `addBulkThen` in TypeScript and Python; `addTree`, `getParentResult` and `getParentResults` in the Bun package only. FlowProducer extends Node.js `EventEmitter` (BullMQ v5 compatible). Its `closing` property is `null` while live, then becomes the stable Promise returned by the first `close()` or `disconnect()` call. Repeated shutdown calls return that same Promise, including if teardown fails. The result helpers stay synchronous in embedded mode for compatibility and return Promises in TCP mode. Always `await` them in portable code. They preserve `0`, `false`, an empty string, and persisted `null`; an ID with no stored result is omitted from the map (or resolves to `undefined` for the single read). The Bun snippets in this guide are exercised in `test/flow-docs-examples.test.ts`, including the complete Quick Start, chain, fan-in, parent-first tree, per-queue defaults, bounded traversal, and both failure-value APIs. ### Job methods inside a worker processor | Method | Description | |--------|-------------| | `job.getChildrenValues()` | Results of all completed children | | `job.getFailedChildrenValues()` | Errors from children that failed with `continueParentOnFailure` | | `job.getIgnoredChildrenFailures()` | Errors from children that failed with `ignoreDependencyOnFailure` | | `job.removeChildDependency()` | Atomically detach this job from its parent; promotes the parent if it was the last pending child | | `job.removeUnprocessedChildren()` | Cancel all waiting/delayed children; active and finished children are unaffected | In the external SDKs, `getChildrenValues` is available on the job in TypeScript and Python and on `Queue` (taking the job id) in TypeScript, Python, PHP and Go; the other four methods live on `Queue` in TypeScript and Python. ### Step shape ```typescript // addChain / addBulkThen / addTree interface FlowStep { name: string; // Job name queueName: string; // Target queue data: T; // Job data opts?: JobOptions; // Optional job options children?: FlowStep[]; // Child steps (addTree) } // flow.add / flow.addBulk (children run BEFORE the parent) interface FlowJob { name: string; queueName: string; data?: T; opts?: JobOptions; children?: FlowJob[]; } ``` ```typescript // addChain / addBulkThen interface FlowStep { name: string; queueName: string; data?: T; opts?: JobOptions; } // flow.add / flow.addBulk (children run BEFORE the parent) interface FlowJob { name: string; queueName: string; data?: T; opts?: JobOptions; children?: FlowJob[]; } ``` ```python # add_chain / add_bulk_then steps and add / add_bulk nodes are plain dicts: step = {"name": "...", "queueName": "...", "data": {}, "opts": {}} node = {"name": "...", "queueName": "...", "data": {}, "opts": {}, "children": []} # children run BEFORE the parent ``` ```php // addChain steps and add() nodes are plain arrays: $step = ['name' => '...', 'queueName' => '...', 'data' => [], 'opts' => []]; $node = ['name' => '...', 'queueName' => '...', 'data' => [], 'opts' => [], 'children' => []]; // children run BEFORE the parent ``` ```go // AddChain type ChainStep struct { Name string QueueName string Data map[string]any Opts JobOptions } // Add (children run BEFORE the parent) type FlowJob struct { Name string QueueName string Data map[string]any Opts JobOptions Children []FlowJob } ``` ```rust // add_chain pub struct ChainStep { pub name: String, pub queue_name: String, pub data: Value, pub options: JobOptions, } // add (children run BEFORE the parent) pub struct FlowJob { pub name: String, pub queue_name: String, pub data: Value, pub options: JobOptions, pub children: Vec, } ``` ```elixir # add_chain steps and add/2 nodes are plain maps (note the :queue key): step = %{name: "...", queue: "...", data: %{}, options: []} node = %{name: "...", queue: "...", data: %{}, options: [], children: []} # children run BEFORE the parent ``` `flow.add()` returns a `JobNode`: `{ job, children? }`, recursively. :::tip[Related Guides] - [Queue API](/guide/queue/) - Job options available on each step - [Worker API](/guide/worker/) - Process flow jobs with workers - [Workflow Engine](/guide/workflow/) - Multi-step orchestration with rollback, when flows are not enough ::: ## Where to go next | | | |---|---| | [Flow Producer](/guide/flow/) | Your first parent/child graph, and what is guaranteed | | [Flow Patterns](/guide/flow/patterns/) | Chains, fan-in, trees, reading child results, options | | [Flow Failure Handling](/guide/flow/failures/) | What a parent does when a child dies for good | --- # bunqueue Glossary: Job Queue Terms in Plain Words Plain-language definitions of every bunqueue concept: job, queue, worker, DLQ, backoff, embedded mode, stall detection, and more. Each term links to its guide. URL: https://bunqueue.dev/guide/glossary/
reference · glossary

Every job queue term, defined.

Short, plain-words definitions. Each term links to the guide that covers it in full. If a word in the docs is unfamiliar, it is explained here.

A **job queue** is a to-do list for your app: you add tasks now, and they run later, in order, with retries if they fail. This page defines the words bunqueue uses, grouped by topic. ## The basics ### Job One unit of work: a name, a JSON payload, and options such as priority or delay. A job moves through states (`waiting`, `active`, `completed`, `failed`, `delayed`) until it finishes or runs out of retries. See the [Queue API](/guide/queue/). ### Queue A named list that holds jobs of one kind, for example `emails`. You add jobs to a queue, workers take them out. Each queue can be paused, drained, or rate limited on its own. See the [Queue API](/guide/queue/). ### Worker A loop that pulls jobs from a queue and runs your function on each one. You choose how many jobs it runs in parallel. See the [Worker API](/guide/worker/). ### Producer Any code that adds jobs. Often just your HTTP handler calling `queue.add()`. See the [Queue API](/guide/queue/). ### Embedded mode vs server mode **Embedded mode** runs the whole queue inside your app's process, in memory by default or backed by a local SQLite file when `dataPath` is set; there is no server to run. **Server mode** runs bunqueue as a standalone server that many apps and workers connect to over TCP, using memory/SQLite for one broker or PostgreSQL for a broker fleet. See [Server Mode](/guide/server/) and the [Introduction](/guide/introduction/). ### Ack The confirmation a worker sends when a job is done. The `Worker` class acks for you automatically when your function returns. See the [Worker API](/guide/worker/). ### Simple mode The `Bunqueue` class, a Queue and a Worker bundled into one object, with named routes and middleware. The fastest way to start. See [Simple Mode](/guide/simple-mode/). ## When things fail ### Retry Running a failed job again. bunqueue retries up to `attempts` times (default 3) before giving up. See the [Worker API](/guide/worker/). ### Backoff The waiting time between retries. Each retry waits longer than the last, which stops a struggling service from being hammered. See the [Dead Letter Queue guide](/guide/dlq/). ### DLQ (Dead Letter Queue) The place where jobs go after all retries fail, with their error and stack trace kept so you can inspect and retry them by hand or on a schedule. See the [Dead Letter Queue guide](/guide/dlq/). ### Stall detection The safety net for crashed workers. A working worker sends heartbeats; if they stop, the job is taken back and re-queued so another worker can run it. See [Stall Detection](/guide/stall-detection/). ### Heartbeat A small "still alive" signal a worker sends while a job runs. Missed heartbeats trigger stall detection. See [Stall Detection](/guide/stall-detection/). ### Lock (lease) Temporary, fenced ownership of a job given to the worker that pulled it. While the lease is valid, only its holder may commit an outcome. If it expires, the job can be handed out again; handlers must therefore tolerate at-least-once execution when a stalled original is still alive. See the [Worker API](/guide/worker/). ### Durable write A job option (`durable: true`) that makes SQLite write the job immediately instead of using its 10ms write buffer. It trades some SQLite throughput for no buffer-loss window. PostgreSQL admissions are already transactional, so the flag does not change server-side durability there. See the [Queue API](/guide/queue/). ## Timing and ordering ### Priority A number on a job; higher numbers run first within the same queue. See the [Queue API](/guide/queue/). ### Delayed job A job that waits a set time before it becomes runnable. It sits in the `delayed` state, then moves to `waiting`. See the [Queue API](/guide/queue/). ### Cron A schedule that adds jobs on a recurring basis, from cron expressions like `0 9 * * *` or plain intervals, with timezone support. Schedules survive restarts when SQLite or PostgreSQL persistence is configured; memory-only schedules do not. See [Cron Jobs](/guide/cron/). ### Promote Moving a delayed job to `waiting` right now, ahead of its schedule. See the [Queue API](/guide/queue/). ### Concurrency How many jobs one worker runs at the same time. A separate queue-level cap can limit active jobs across all workers. See the [Worker API](/guide/worker/) and [Rate Limiting](/guide/rate-limiting/). ### Rate limiting Capping how many jobs run per time window, to protect a downstream service like an email API from overload. See [Rate Limiting](/guide/rate-limiting/). ### Deduplication and idempotency Giving a job a custom `jobId` so adding it twice does nothing the second time. This makes `add()` safe to call more than once for the same logical task. See the [Queue API](/guide/queue/). ## Composing jobs ### Flow Parent-child job dependencies: children run first, the parent runs only after all children complete. Built with `FlowProducer`. See the [Flow Producer guide](/guide/flow/). ### Workflow and saga compensation The Workflow Engine runs multi-step processes with branching, parallel steps, loops, and waits for human approval. **Saga compensation** means each step can register an undo function, and on failure the completed steps are undone in reverse order. See the [Workflow Engine guide](/guide/workflow/). ### Queue group Several queues managed as one unit, useful for tenant-per-queue setups. See [Queue Group](/guide/queue-group/). ### Webhook An HTTP call bunqueue makes to your URL when queue events happen, so other systems can react. See [Webhooks](/guide/webhooks/). ## Control operations ### Pause and resume Pausing stops workers from receiving new jobs from a queue; jobs already running finish normally. Resume turns delivery back on. See the [Queue API](/guide/queue/). ### Drain and obliterate **Drain** removes waiting and delayed jobs but lets active ones finish. **Obliterate** deletes the queue and everything in it. See the [Queue API](/guide/queue/). ## Under the hood ### Sharding In memory/SQLite mode, bunqueue splits queue state across independent in-memory slices (one per CPU core group) so operations on different queues do not wait on one lock. It is automatic. PostgreSQL mode orders and locks authoritative database rows instead of using those delivery shards. See [Benchmarks](/guide/benchmarks/). ### WAL (Write-Ahead Logging) The SQLite mode bunqueue uses, which lets reads and writes happen at the same time. It creates `-wal` and `-shm` files next to the database file. See [Storage](/guide/databases/). ### MessagePack The compact binary format used on the TCP wire, smaller and faster to parse than JSON. See the [TCP Protocol](/api/tcp/). ### Store-and-forward An edge pattern: a small embedded queue stores jobs on a persistent local volume, then forwards them to a central server when it can reach it. A network outage does not drop jobs while the local process and volume survive; use SQLite `durable: true` when even its 10ms hard-crash window is unacceptable. See [IoT & Edge](/guide/iot-edge/). :::tip[Related] - [Introduction](/guide/introduction/) - What bunqueue is and when to use it - [Quickstart](/guide/quickstart/) - Running in five minutes - [FAQ](/faq/) - Common questions answered ::: --- # Adding Jobs: Priorities, Delays and Bulk Everything about putting work into a bunqueue queue: single and bulk adds, priorities, delays, per-job attempts and timeouts, and durable writes that skip the buffer. URL: https://bunqueue.dev/guide/queue/adding-jobs/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Getting work into the queue.

One job or a hundred thousand, ordered by priority, held back by a delay, or written straight to disk when losing it is not an option.

## Add jobs ```typescript const basicJob = await queue.add('job-name', { key: 'value' }); // With options const configuredJob = await queue.add('job-name', data, { priority: 10, // Higher = processed first delay: 5000, // Wait 5s before processing attempts: 5, // Max total executions, first run included (default: 3) backoff: 2000, // Exponential base delay in ms (default: 1000, jitter applied, capped at 1h) // OR: backoff: { type: 'exponential', delay: 2000 } // 'fixed' | 'exponential' timeout: 30000, // Fail the job if processing takes longer jobId: 'custom-id', // Custom ID, makes the add idempotent (see Deduplication) removeOnComplete: true, // Delete job data after it completes }); ``` ```typescript const basicJob = await queue.add('job-name', { key: 'value' }); // With options const configuredJob = await queue.add('job-name', data, { priority: 10, // Higher = processed first delay: 5000, // Wait 5s before processing attempts: 5, // Max total executions, first run included (default: 3) backoff: 2000, // Exponential base delay in ms (default: 1000, jitter applied, capped at 1h) // OR: backoff: { type: 'exponential', delay: 2000 } // 'fixed' | 'exponential' timeout: 30000, // Fail the job if processing takes longer jobId: 'custom-id', // Custom ID, makes the add idempotent (see Deduplication) removeOnComplete: true, // Delete job data after it completes }); ``` ```python job = queue.add("job-name", {"key": "value"}) # With options job = queue.add( "job-name", data, priority=10, # Higher = processed first delay=5000, # Wait 5s before processing attempts=5, # Max total executions, first run included (default: 3) backoff=2000, # Exponential base delay in ms (default: 1000) # OR: backoff={"type": "exponential", "delay": 2000} timeout=30000, # Fail the job if processing takes longer job_id="custom-id", # Custom ID, makes the add idempotent remove_on_complete=True, # Delete job data after it completes ) ``` ```php $job = $queue->add('job-name', ['key' => 'value']); // With options $job = $queue->add('job-name', $data, [ 'priority' => 10, // Higher = processed first 'delay' => 5000, // Wait 5s before processing 'attempts' => 5, // Max total executions (default: 3) 'backoff' => 2000, // Or ['type' => 'exponential', 'delay' => 2000] 'timeout' => 30000, // Fail the job if processing takes longer 'jobId' => 'custom-id', // Custom ID, makes the add idempotent 'removeOnComplete' => true, // Delete job data after it completes ]); ``` ```go job, err := queue.Add("job-name", map[string]any{"key": "value"}, nil) // With options job, err = queue.Add("job-name", data, bunqueue.JobOptions{ "priority": 10, // Higher = processed first "delay": 5000, // Wait 5s before processing "attempts": 5, // Max total executions (default: 3) "backoff": 2000, // Or map[string]any{"type": "exponential", "delay": 2000} "timeout": 30000, // Fail the job if processing takes longer "jobId": "custom-id", // Custom ID, makes the add idempotent "removeOnComplete": true, // Delete job data after it completes }) ``` ```rust use bunqueue_client::{Backoff, JobOptions}; // With options let job = queue.add("job-name", data, JobOptions { priority: Some(10), // Higher = processed first delay: Some(5000), // Wait 5s before processing attempts: Some(5), // Max total executions (default: 3) backoff: Some(Backoff::Milliseconds(2000)), // OR: Backoff::Strategy { kind: "exponential".into(), delay: 2000, max_delay: None } timeout: Some(30_000), // Fail the job if processing takes longer job_id: Some("custom-id".into()), // Custom ID, makes the add idempotent remove_on_complete: Some(true), // Delete job data after it completes ..Default::default() })?; ``` ```elixir {:ok, job} = Bunqueue.Queue.add(queue, "job-name", %{key: "value"}) # With options {:ok, job} = Bunqueue.Queue.add(queue, "job-name", data, priority: 10, # Higher = processed first delay: 5000, # Wait 5s before processing attempts: 5, # Max total executions (default: 3) backoff: 2000, # Or %{type: "exponential", delay: 2000} timeout: 30_000, # Fail the job if processing takes longer jobId: "custom-id", # Custom ID, makes the add idempotent removeOnComplete: true # Delete job data after it completes ) ``` The full option list is in the [reference table](/guide/queue/options/). `timeout` starts when the broker marks the job active. The broker tracks the absolute processing deadline and fails the job with reason `timeout` when it is reached; it is not rounded to a maintenance sweep interval. ### Add many at once `addBulk` inserts all jobs in one batch, much faster than a loop of `add`: ```typescript const jobs = await queue.addBulk([ { name: 'task-1', data: { id: 1 } }, { name: 'task-2', data: { id: 2 }, opts: { priority: 10 } }, { name: 'task-3', data: { id: 3 }, opts: { delay: 5000 } }, ]); ``` ```typescript const jobs = await queue.addBulk([ { name: 'task-1', data: { id: 1 } }, { name: 'task-2', data: { id: 2 }, opts: { priority: 10 } }, { name: 'task-3', data: { id: 3 }, opts: { delay: 5000 } }, ]); ``` ```python # Each entry: {"name", "data", ...options} with the same names as add() ids = queue.add_bulk([ {"name": "task-1", "data": {"id": 1}}, {"name": "task-2", "data": {"id": 2}, "priority": 10}, {"name": "task-3", "data": {"id": 3}, "delay": 5000}, ]) ``` ```php // Each entry: name + data + options, flattened $ids = $queue->addBulk([ ['name' => 'task-1', 'data' => ['id' => 1]], ['name' => 'task-2', 'data' => ['id' => 2], 'priority' => 10], ['name' => 'task-3', 'data' => ['id' => 3], 'delay' => 5000], ]); ``` ```go ids, err := queue.AddBulk([]bunqueue.BulkEntry{ {Name: "task-1", Data: map[string]any{"id": 1}}, {Name: "task-2", Data: map[string]any{"id": 2}, Opts: bunqueue.JobOptions{"priority": 10}}, {Name: "task-3", Data: map[string]any{"id": 3}, Opts: bunqueue.JobOptions{"delay": 5000}}, }) ``` ```rust use bunqueue_client::{BulkEntry, JobOptions, Value}; let ids = queue.add_bulk(vec![ BulkEntry { name: "task-1".into(), data: Value::Nil, options: JobOptions::default(), }, BulkEntry { name: "task-2".into(), data: Value::Nil, options: JobOptions { priority: Some(10), ..Default::default() }, }, BulkEntry { name: "task-3".into(), data: Value::Nil, options: JobOptions { delay: Some(5000), ..Default::default() }, }, ])?; ``` ```elixir {:ok, ids} = Bunqueue.Queue.add_bulk(queue, [ %{name: "task-1", data: %{id: 1}}, %{name: "task-2", data: %{id: 2}, opts: [priority: 10]}, %{name: "task-3", data: %{id: 3}, opts: [delay: 5000]} ]) ``` On memory/SQLite brokers, `addBulk` is ordered and uses accepted-prefix semantics, including over TCP. If a later entry is rejected (for example by a group `maxSize` limit), earlier accepted entries remain queued and later entries are not evaluated. The rejected entry is never left as a hidden in-memory job. PostgreSQL brokers commit `addBulk` in one transaction: an admission error rolls back the batch. Use `FlowProducer` when the complete graph must commit atomically across every backend. ### Repeat on a schedule ```typescript // Every 5 seconds await queue.add('heartbeat', {}, { repeat: { every: 5000 } }); // Every 24 hours, at most 30 times await queue.add('daily-report', {}, { repeat: { every: 86400000, limit: 30 } }); // Cron pattern await queue.add('weekly', {}, { repeat: { pattern: '0 9 * * MON' } }); ``` ```typescript // Every 5 seconds await queue.add('heartbeat', {}, { repeat: { every: 5000 } }); // Every 24 hours, at most 30 times await queue.add('daily-report', {}, { repeat: { every: 86400000, limit: 30 } }); // Cron pattern await queue.add('weekly', {}, { repeat: { pattern: '0 9 * * MON' } }); ``` ```python # Every 5 seconds queue.add("heartbeat", {}, repeat={"every": 5000}) # Every 24 hours, at most 30 times queue.add("daily-report", {}, repeat={"every": 86400000, "limit": 30}) # Cron pattern queue.add("weekly", {}, repeat={"pattern": "0 9 * * MON"}) ``` ```php // Every 5 seconds $queue->add('heartbeat', [], ['repeat' => ['every' => 5000]]); // Every 24 hours, at most 30 times $queue->add('daily-report', [], ['repeat' => ['every' => 86400000, 'limit' => 30]]); // Cron pattern $queue->add('weekly', [], ['repeat' => ['pattern' => '0 9 * * MON']]); ``` ```go // Every 5 seconds queue.Add("heartbeat", nil, bunqueue.JobOptions{"repeat": map[string]any{"every": 5000}}) // Every 24 hours, at most 30 times queue.Add("daily-report", nil, bunqueue.JobOptions{ "repeat": map[string]any{"every": 86400000, "limit": 30}, }) // Cron pattern queue.Add("weekly", nil, bunqueue.JobOptions{ "repeat": map[string]any{"pattern": "0 9 * * MON"}, }) ``` ```rust use bunqueue_client::{JobOptions, Value}; // Every 5 seconds let repeat = Value::Map(vec![(Value::from("every"), Value::from(5000))]); queue.add("heartbeat", Value::Nil, JobOptions { repeat: Some(repeat), ..Default::default() })?; // Cron pattern let repeat = Value::Map(vec![(Value::from("pattern"), Value::from("0 9 * * MON"))]); queue.add("weekly", Value::Nil, JobOptions { repeat: Some(repeat), ..Default::default() })?; ``` ```elixir # Every 5 seconds {:ok, _} = Bunqueue.Queue.add(queue, "heartbeat", %{}, repeat: %{every: 5000}) # Every 24 hours, at most 30 times {:ok, _} = Bunqueue.Queue.add(queue, "daily-report", %{}, repeat: %{every: 86_400_000, limit: 30}) # Cron pattern {:ok, _} = Bunqueue.Queue.add(queue, "weekly", %{}, repeat: %{pattern: "0 9 * * MON"}) ``` You can change the data for future runs at any point in the lifecycle with `updateData()`, even after the current run completes (the update follows the repeat chain to the next scheduled execution): ```typescript const job = await queue.add('sync', { endpoint: '/api/v1' }, { repeat: { every: 60000 } }); await job.updateData({ endpoint: '/api/v2' }); // Next run uses /api/v2 ``` ```typescript const job = await queue.add('sync', { endpoint: '/api/v1' }, { repeat: { every: 60000 } }); await job.updateData({ endpoint: '/api/v2' }); // Next run uses /api/v2 ``` ```python job = queue.add("sync", {"endpoint": "/api/v1"}, repeat={"every": 60000}) job.update_data({"endpoint": "/api/v2"}) # Next run uses /api/v2 ``` ```php $job = $queue->add('sync', ['endpoint' => '/api/v1'], ['repeat' => ['every' => 60000]]); $queue->updateJobData($job->id(), ['endpoint' => '/api/v2']); // Next run uses /api/v2 ``` ```go job, _ := queue.Add("sync", map[string]any{"endpoint": "/api/v1"}, bunqueue.JobOptions{"repeat": map[string]any{"every": 60000}}) queue.UpdateJobData(job.ID(), map[string]any{"endpoint": "/api/v2"}) // Next run uses /api/v2 ``` ```rust let repeat = Value::Map(vec![(Value::from("every"), Value::from(60_000))]); let job = queue.add("sync", data, JobOptions { repeat: Some(repeat), ..Default::default() })?; let update = Value::Map(vec![(Value::from("endpoint"), Value::from("/api/v2"))]); queue.update_job_data(&job.id(), update)?; // Next run uses /api/v2 ``` ```elixir {:ok, job} = Bunqueue.Queue.add(queue, "sync", %{endpoint: "/api/v1"}, repeat: %{every: 60_000}) :ok = Bunqueue.Queue.update(queue, job.id, %{endpoint: "/api/v2"}) # Next run uses /api/v2 ``` For named, managed schedules, see [Job Schedulers](/guide/queue/schedulers/) and the [Cron guide](/guide/cron/). ### Durable jobs (no SQLite buffer-loss window) By default SQLite mode batches writes to disk for up to 10 ms. A crash inside that window can lose the not-yet-flushed jobs. For jobs where that is unacceptable, `durable: true` bypasses bunqueue's buffer and commits before `add()` returns. Host, filesystem, and physical-media durability still apply: ```typescript await queue.add( 'process-payment', { orderId: '123', amount: 99.99 }, { durable: true, } ); ``` ```typescript await queue.add( 'process-payment', { orderId: '123', amount: 99.99 }, { durable: true, } ); ``` ```python queue.add("process-payment", {"order_id": "123", "amount": 99.99}, durable=True) ``` ```php $queue->add('process-payment', ['orderId' => '123', 'amount' => 99.99], [ 'durable' => true, ]); ``` ```go queue.Add("process-payment", map[string]any{"orderId": "123", "amount": 99.99}, bunqueue.JobOptions{"durable": true}) ``` ```rust queue.add("process-payment", data, JobOptions { durable: Some(true), ..Default::default() })?; ``` ```elixir {:ok, _job} = Bunqueue.Queue.add(queue, "process-payment", %{order_id: "123", amount: 99.99}, durable: true ) ``` SQLite durable acceptance is fail closed. `add()` resolves only after SQLite commits the job and any related custom-ID retirement, dedup replacement, dependency pin, or parent link. If SQLite rejects the write—for example because the disk is full—the call rejects and that candidate is not queryable, counted, or available to a Worker. Reusing a completed or DLQ `jobId` is also atomic: a failed replacement preserves the previous generation and its result across a broker restart. The same SQLite contract applies in Embedded and TCP mode and to durable entries in `addBulk`. PostgreSQL admission is transactional whether or not the flag is set. Memory-only mode remains ephemeral: `durable: true` cannot make it survive a process restart. | SQLite mode | Published native workload median | Data loss window | Use for | | ----------- | ---------------------------------------: | --------------------------- | ------------------------------- | | Default | 186,384 jobs/s, public on-disk `addBulk` | Up to 10 ms | Re-creatable work | | Durable | 60,835 ops/s, sequential Embedded adds | No SQLite buffer-loss window after `add()` resolves | Payments, orders, audit records | Those figures label different workloads and are not a direct per-operation speedup ratio. See [Benchmarks](/guide/benchmarks/) for distributions and the TCP rows. PostgreSQL admissions are already transactional and do not use this SQLite buffer. ## Where to go next | Guide | What it covers | | ------------------------------------------------------------------------- | --------------------------------------------- | | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Namespaces, Auto-Batching and Store-and-Forward Advanced bunqueue queue behaviour: prefixKey namespace isolation for multi-tenant servers, transparent TCP auto-batching, and forwarding local jobs to a central server. URL: https://bunqueue.dev/guide/queue/advanced/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Sharing a server, and leaving it.

Namespacing so several environments can share one broker, batching that makes concurrent adds an order of magnitude faster, and draining an edge queue into a central one.

## Namespace Isolation (`prefixKey`) `prefixKey` namespaces queue membership, crons, stats, pause state, DLQ, and rate limits on a shared broker. The client prefixes the broker queue key; `Queue.name` keeps reporting the logical name. Custom `jobId` values remain broker-wide, so include your tenant/environment in each custom ID when it must be isolated too. ```typescript // Same server, fully isolated namespaces const devQueue = new Queue('emails', { prefixKey: 'dev:' }); const prodQueue = new Queue('emails', { prefixKey: 'prod:' }); await devQueue.add('send', { to: 'tester@example.com' }); await prodQueue.getJobCountsAsync(); // never sees dev jobs ``` ```typescript // Same server, fully isolated namespaces const devQueue = new Queue('emails', { prefixKey: 'dev:' }); const prodQueue = new Queue('emails', { prefixKey: 'prod:' }); await devQueue.add('send', { to: 'tester@example.com' }); await prodQueue.getJobCountsAsync(); // never sees dev jobs ``` ```python # Same server, fully isolated namespaces dev_queue = Queue("emails", prefix_key="dev:") prod_queue = Queue("emails", prefix_key="prod:") dev_queue.add("send", {"to": "tester@example.com"}) prod_queue.get_job_counts() # never sees dev jobs ``` ```php // Prefix the broker queue name directly. $devQueue = new Queue('dev:emails'); $prodQueue = new Queue('prod:emails'); $devQueue->add('send', ['to' => 'tester@example.com']); $prodQueue->getJobCounts(); // never sees dev jobs ``` ```go // Prefix the broker queue name directly. devQueue := bunqueue.NewQueue("dev:emails", bunqueue.Options{}) prodQueue := bunqueue.NewQueue("prod:emails", bunqueue.Options{}) devQueue.Add("send", map[string]any{"to": "tester@example.com"}, nil) prodQueue.GetJobCounts() // never sees dev jobs ``` ```rust // Prefix the broker queue name directly. let dev_queue = Queue::new("dev:emails", ConnectionOptions::default()); let prod_queue = Queue::new("prod:emails", ConnectionOptions::default()); dev_queue.add("send", data, JobOptions::default())?; let counts = prod_queue.get_job_counts()?; // never sees dev jobs ``` ```elixir # Prefix the broker queue name directly. dev_queue = Bunqueue.queue("dev:emails") prod_queue = Bunqueue.queue("prod:emails") {:ok, _job} = Bunqueue.Queue.add(dev_queue, "send", %{to: "tester@example.com"}) {:ok, _counts} = Bunqueue.Queue.get_job_counts(prod_queue) # never sees dev jobs ``` _A `prefixKey` option exists in both TypeScript packages and the Python SDK. In the other SDKs, prefix the queue name directly (e.g. `new Queue('dev:emails')`). Custom job IDs still need an explicit namespace._ A Worker must use the same `prefixKey` to consume the prefixed queue: ```typescript const devWorker = new Worker('emails', processor, { prefixKey: 'dev:' }); ``` ```typescript const devWorker = new Worker('emails', processor, { prefixKey: 'dev:' }); ``` ```python dev_worker = Worker("dev:emails", process) ``` ```php $devWorker = new Worker('dev:emails', $processor); ``` ```go devWorker := bunqueue.NewWorker("dev:emails", processor, bunqueue.WorkerOptions{}) ``` ```rust let dev_worker = Worker::new("dev:emails", processor, WorkerOptions::default()); ``` ```elixir dev_worker = Bunqueue.Worker.new("dev:emails", processor) ``` _In the other SDKs, give the Worker the prefixed name (e.g. `new Worker('dev:emails', processor)`)._ Common patterns: `dev:` / `staging:` / `prod:` on one server, `tenant-${id}:` per customer, per-service prefixes in a monorepo, `test-${runId}:` for parallel test isolation. Notes: - Queue membership, worker locks, counts, pause/drain/obliterate, rate limits, and cron schedulers are scoped by the prefixed queue key (two prefixes can reuse the same `schedulerId`). - Custom `jobId` ownership is broker-wide: `dev:order-123` and `prod:order-123` are distinct; two queues using plain `order-123` refer to the same live identity. A prefix is naming isolation, not an authorization boundary. - Backward compatible: without `prefixKey`, behavior is unchanged. Works in embedded and TCP modes. - The only user-visible side effect: `Job.queueName` inside processors shows the prefixed key (e.g. `dev:emails`). ## Auto-batching (TCP mode) In TCP mode, concurrent `queue.add()` calls are transparently combined into single bulk commands. It is enabled by default with no code changes: sequential `await add()` sends immediately, while concurrent adds (`Promise.all`) can share one round trip. Throughput depends on batch shape, durability, database size, and backend; use the current [benchmark workloads](/guide/benchmarks/) instead of treating an older point measurement as a universal rate. ```typescript const queue = new Queue('tasks', { autoBatch: { enabled: true, // default maxSize: 50, // flush when the buffer reaches this size (default: 50) maxDelayMs: 5, // max wait before flushing (default: 5) }, }); ``` ```typescript const queue = new Queue('tasks', { autoBatch: { enabled: true, // default maxSize: 50, // flush when the buffer reaches this size (default: 50) maxDelayMs: 5, // max wait before flushing (default: 5) }, }); ``` ```python # The network SDK sends this batch in one round-trip. queue.add_bulk([ {"name": "task", "data": {"id": 1}}, {"name": "task", "data": {"id": 2}}, ]) ``` ```php // The network SDK sends this batch in one round-trip. $queue->addBulk([ ['name' => 'task', 'data' => ['id' => 1]], ['name' => 'task', 'data' => ['id' => 2]], ]); ``` ```go // The network SDK sends this batch in one round-trip. ids, err := queue.AddBulk([]bunqueue.BulkEntry{ {Name: "task", Data: map[string]any{"id": 1}}, {Name: "task", Data: map[string]any{"id": 2}}, }) ``` ```rust // The network SDK sends this batch in one round-trip. let ids = queue.add_bulk(vec![ BulkEntry { name: "task".into(), data: Value::from(1), options: JobOptions::default() }, BulkEntry { name: "task".into(), data: Value::from(2), options: JobOptions::default() }, ])?; ``` ```elixir # The network SDK sends this batch in one round-trip. {:ok, ids} = Bunqueue.Queue.add_bulk(queue, [ %{name: "task", data: %{id: 1}}, %{name: "task", data: %{id: 2}} ]) ``` _Auto-batching is available in both TypeScript packages (`bunqueue/client` and `bunqueue-client`); in the other SDKs, use `addBulk` to batch producer traffic into one round-trip._ :::caution[Durable jobs bypass the batcher] Jobs with `durable: true` are always sent individually rather than through the client batcher. On a SQLite server they also bypass its write buffer; PostgreSQL admission is already transactional. ::: ## Store-and-forward: `queue.forward()` Drain a source queue to a remote bunqueue server. The usual edge/IoT pattern uses an embedded SQLite queue as the offline buffer and a central server as the destination; the same API also supports a TCP source broker: ```typescript const forwarder = queue.forward({ to: { host: 'queue.example.com', port: 6789, tls: true, token: process.env.BQ_TOKEN }, queue: 'central-name', // optional remote queue name (default: same) concurrency: 4, // parallel forwards (default: 4) durable: true, // push remotely with durable: true (default: false) }); forwarder.on('forwarded', ({ id, remoteId, name }) => {}); forwarder.on('error', (err) => {}); await forwarder.close(); ``` ```typescript const forwarder = queue.forward({ to: { host: 'queue.example.com', port: 6789, tls: true, token: process.env.BQ_TOKEN }, queue: 'central-name', // optional remote queue name (default: same) concurrency: 4, // parallel forwards (default: 4) durable: true, // push remotely with durable: true (default: false) }); forwarder.on('forwarded', ({ id, remoteId, name }) => {}); forwarder.on('error', (err) => {}); await forwarder.close(); ``` The network SDK has no local embedded buffer. When offline buffering is not required, write directly to the central broker: ```python central = Queue( "central-name", host="queue.example.com", port=6789, token=os.environ["BQ_TOKEN"], ) central.add("event", data, durable=True) ``` The network SDK has no local embedded buffer. When offline buffering is not required, write directly to the central broker: ```php $central = new Queue('central-name', [ 'host' => 'queue.example.com', 'port' => 6789, 'token' => getenv('BQ_TOKEN'), ]); $central->add('event', $data, ['durable' => true]); ``` The network SDK has no local embedded buffer. When offline buffering is not required, write directly to the central broker: ```go central := bunqueue.NewQueue("central-name", bunqueue.Options{ Host: "queue.example.com", Port: 6789, Token: os.Getenv("BQ_TOKEN"), }) central.Add("event", data, bunqueue.JobOptions{"durable": true}) ``` The network SDK has no local embedded buffer. When offline buffering is not required, write directly to the central broker: ```rust let central = Queue::new("central-name", ConnectionOptions { host: "queue.example.com".into(), port: 6789, token: std::env::var("BQ_TOKEN").ok(), ..Default::default() }); central.add("event", data, JobOptions { durable: Some(true), ..Default::default() })?; ``` The network SDK has no local embedded buffer. When offline buffering is not required, write directly to the central broker: ```elixir central = Bunqueue.queue("central-name", host: "queue.example.com", port: 6789, token: System.fetch_env!("BQ_TOKEN") ) {:ok, _job} = Bunqueue.Queue.add(central, "event", data, durable: true) ``` _Only the Bun-runtime `bunqueue` package provides the `forward()` drain loop. Its source may be embedded or TCP, but only an embedded source provides the in-process SQLite offline buffer. Direct network writes do not retain jobs locally while the central broker is unavailable._ If the remote is down, locally persisted jobs stay in the source queue (retry, then DLQ) while that process and volume survive. Use `durable: true` locally if SQLite's 10ms hard-crash window is unacceptable. Full guide: [IoT & Edge](/guide/iot-edge/). ## Where to go next | Guide | What it covers | | -------------------------------------------------------------------- | --------------------------------------------- | | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Queue Control and Maintenance Operate a live bunqueue queue: pause and resume, drain, obliterate, clean old jobs by age and state, retry in bulk and promote delayed jobs on demand. URL: https://bunqueue.dev/guide/queue/control/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Pause it, drain it, wipe it.

The operational verbs. Stop consumption during an incident, clear a backlog, remove finished jobs before they accumulate, and push delayed work forward when you cannot wait.

## Control the queue ```typescript queue.pause(); // Workers stop pulling (fire-and-forget) queue.resume(); // Back to normal (fire-and-forget) queue.drain(); // Remove all waiting/delayed jobs (fire-and-forget) queue.obliterate(); // Remove ALL queue data (fire-and-forget) await queue.pauseAsync(); // Pause and wait for it await queue.resumeAsync(); // Resume and wait for it const n = await queue.drainAsync(); // Drain, wait, get removed count await queue.obliterateAsync(); // Remove ALL queue data and wait for it queue.remove('job-id'); // Remove one job (fire-and-forget) await queue.removeAsync('job-id'); // Remove one job and wait for it await queue.waitUntilReady(); // Wait until queue/server is ready queue.close(); // Close TCP connection (no-op in embedded mode) ``` ```typescript await queue.pauseAsync(); // Stop new pulls and wait for the broker await queue.resumeAsync(); // Resume the queue const n = await queue.drainAsync(); // Remove waiting/delayed jobs, return count await queue.obliterateAsync(); // Remove all queue data await queue.removeAsync('job-id'); // Remove one job and wait for the broker await queue.waitUntilReady(); await queue.disconnect(); // Flush pending adds and close the connection ``` ```python queue.pause() # Workers stop pulling queue.resume() # Back to normal n = queue.drain() # Remove all waiting/delayed jobs, get removed count queue.obliterate() # Remove ALL queue data queue.remove("job-id") # Remove one job queue.wait_until_ready() # Wait until the server is reachable queue.close() # Close the TCP connection ``` ```php $queue->pause(); // Workers stop pulling $queue->resume(); // Back to normal $n = $queue->drain(); // Remove all waiting/delayed jobs, get removed count $queue->obliterate(); // Remove ALL queue data $queue->remove('job-id'); // Remove one job $queue->close(); // Close the TCP connection ``` ```go queue.Pause() // Workers stop pulling queue.Resume() // Back to normal n, _ := queue.Drain() // Remove all waiting/delayed jobs, get removed count queue.Obliterate() // Remove ALL queue data queue.Remove("job-id") // Remove one job queue.Close() // Close the TCP connection ``` ```rust queue.pause()?; // Workers stop pulling queue.resume()?; // Back to normal let n = queue.drain()?; // Remove all waiting/delayed jobs, get removed count queue.obliterate()?; // Remove ALL queue data queue.remove("job-id")?; // Remove one job queue.close(); // Close the TCP connection ``` ```elixir :ok = Bunqueue.Queue.pause(queue) # Workers stop pulling :ok = Bunqueue.Queue.resume(queue) # Back to normal {:ok, n} = Bunqueue.Queue.drain(queue) # Remove all waiting/delayed jobs, get removed count :ok = Bunqueue.Queue.obliterate(queue) # Remove ALL queue data :ok = Bunqueue.Queue.close(queue) # Close the TCP connection ``` Gotcha: in TCP mode the fire-and-forget forms return before the server has processed them. If you drain or obliterate and immediately add new jobs, the wipe can land after the add and delete the new job. Use the `Async` variants when the next step depends on the command being done. ## Maintenance ```typescript // Remove completed jobs older than 1 hour, max 100 (async works in both modes) const removed = await queue.cleanAsync(3600000, 100, 'completed'); // Promote delayed jobs to waiting now const promoted = await queue.promoteJobs({ count: 50 }); // Re-queue failed jobs from the DLQ await queue.retryJobs({ state: 'failed', count: 100 }); // Re-queue completed jobs through the same selector contract await queue.retryJobs({ state: 'completed', count: 100, timestamp: Date.now() - 3600000, // completed at least one hour ago }); // Direct completed-job helpers (e.g. after a logic change) const count = await queue.retryCompletedAsync(); // all completed, use with care const one = queue.retryCompleted('job-id-123'); // one job (sync, embedded; TCP returns 0) ``` ```typescript // Remove completed jobs older than 1 hour, max 100 (async works in both modes) const removed = await queue.cleanAsync(3600000, 100, 'completed'); // Promote delayed jobs to waiting now const promoted = await queue.promoteJobs({ count: 50 }); // Re-queue failed jobs from the DLQ await queue.retryJobs({ state: 'failed', count: 100 }); // Re-queue completed jobs through the same selector contract await queue.retryJobs({ state: 'completed', count: 100, timestamp: Date.now() - 3600000, // completed at least one hour ago }); // Direct completed-job helpers (e.g. after a logic change) const count = await queue.retryCompletedAsync(); // all completed, use with care const one = await queue.retryCompletedAsync('job-id-123'); // one job; returns the broker count ``` ```python # Remove completed jobs older than 1 hour, max 100 removed = queue.clean(3600000, 100, "completed") # Promote delayed jobs to waiting now promoted = queue.promote_jobs(50) # Re-queue failed jobs from the DLQ queue.retry_jobs("failed", 100) # Re-queue completed jobs (e.g. after a logic change) queue.retry_completed() # all completed, use with care queue.retry_completed("job-id-123") # one job ``` ```php // Remove completed jobs older than 1 hour, max 100 (returns removed job ids) $removed = $queue->clean(3600000, 100, 'completed'); // Promote one delayed job to waiting now $queue->promote('job-id'); // Re-queue one failed job (failed -> waiting) $queue->retryJob('job-id'); ``` ```go // Remove completed jobs older than 1 hour, max 100 (returns removed job ids) removed, _ := queue.Clean(3600000, 100, "completed") // Promote one delayed job to waiting now queue.Promote("job-id") // Re-queue one failed job (failed -> waiting) queue.RetryJob("job-id") ``` ```rust // Remove completed jobs older than 1 hour, max 100 (returns removed job ids) let removed = queue.clean(3_600_000, 100, "completed")?; // Promote one delayed job to waiting now queue.promote("job-id")?; // Re-queue one failed job (failed -> waiting) queue.retry_job("job-id")?; ``` ```elixir # Remove completed jobs older than 1 hour, max 100 (returns removed job ids) {:ok, removed} = Bunqueue.Queue.clean(queue, 3_600_000, 100, "completed") # Promote all delayed jobs to waiting now :ok = Bunqueue.Queue.promote_jobs(queue) # Re-queue one failed job (failed -> waiting) :ok = Bunqueue.Queue.retry_job(queue, "job-id") ``` *Bulk `retryJobs`, `retryCompleted`, and counted `promoteJobs` are available in TypeScript and Python; PHP, Go, and Rust act per job (`promote`, `retryJob`); Elixir exposes an uncounted bulk `promote_jobs/1`.* For SQLite queues, completed cleanup queries the database rather than only the bounded in-memory cache. It removes the oldest eligible rows first with `id` as a deterministic tie-breaker, so repeated calls page through all retained history even when it exceeds `maxCompletedJobs`. The job, result, and related flow-failure rows are deleted in one transaction; the returned IDs are exactly the committed deletions. A completed dependency whose result is still needed by a live consumer is skipped until that consumer is removed or resolved. Retrying a completed job starts a new waiting execution. Its previous `returnvalue`, progress/message, `processedOn`, and `finishedOn` are cleared; attempts restart at zero, while the diagnostic stacktrace and timeline history remain available. For persisted queues, that reset and removal of the old result are atomic in the selected backend: one SQLite transaction in single-broker mode, or a PostgreSQL transaction with its durable event in multi-broker mode. The cleared state therefore survives broker restart. ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Deduplication and Idempotent Job Adds Stop the same job being queued twice in bunqueue: custom job ids for idempotent adds, deduplication keys with a TTL, and how to look up or clear an existing key. URL: https://bunqueue.dev/guide/queue/deduplication/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

The same job, only once.

Retried HTTP calls, at-least-once webhooks and impatient users all produce duplicate adds. A custom id or a dedup key makes the second one a no-op instead of a second charge.

## Deduplication ### With `jobId` (idempotent adds) Give a job a custom `jobId` and adding it twice does nothing: while a generation with that ID is live (`waiting`, `delayed`, `prioritized`, `waiting-children`, or `active`), the existing job is returned instead of creating a duplicate. Custom IDs are broker-wide because they are also the global persisted job primary key, so this remains idempotent even if the second add targets another queue. Once the prior generation is terminal, the ID may be reused; bunqueue retires its completed/DLQ state before admitting exactly one fresh generation. This makes `add()` safe to call repeatedly in embedded and TCP modes. ```typescript const job1 = await queue.add('process', { orderId: 123 }, { jobId: 'order-123' }); const job2 = await queue.add('process', { orderId: 123 }, { jobId: 'order-123' }); console.log(job1.id === job2.id); // true, same job returned ``` ```typescript const job1 = await queue.add('process', { orderId: 123 }, { jobId: 'order-123' }); const job2 = await queue.add('process', { orderId: 123 }, { jobId: 'order-123' }); console.log(job1.id === job2.id); // true, same job returned ``` ```python job1 = queue.add("process", {"order_id": 123}, job_id="order-123") job2 = queue.add("process", {"order_id": 123}, job_id="order-123") print(job1.id == job2.id) # True, same job returned ``` ```php $job1 = $queue->add('process', ['orderId' => 123], ['jobId' => 'order-123']); $job2 = $queue->add('process', ['orderId' => 123], ['jobId' => 'order-123']); var_dump($job1->id() === $job2->id()); // true, same job returned ``` ```go job1, _ := queue.Add("process", map[string]any{"orderId": 123}, bunqueue.JobOptions{"jobId": "order-123"}) job2, _ := queue.Add("process", map[string]any{"orderId": 123}, bunqueue.JobOptions{"jobId": "order-123"}) fmt.Println(job1.ID() == job2.ID()) // true, same job returned ``` ```rust let opts = || JobOptions { job_id: Some("order-123".into()), ..Default::default() }; let job1 = queue.add("process", data.clone(), opts())?; let job2 = queue.add("process", data, opts())?; assert_eq!(job1.id(), job2.id()); // same job returned ``` ```elixir {:ok, job1} = Bunqueue.Queue.add(queue, "process", %{order_id: 123}, jobId: "order-123") {:ok, job2} = Bunqueue.Queue.add(queue, "process", %{order_id: 123}, jobId: "order-123") job1.id == job2.id # true, same job returned ``` Typical uses: webhook retries, double-submits from a UI, restoring jobs on service startup without duplicating them. :::note[Completed ids are reused, not returned] Idempotency collapses onto every unfinished generation, including a job that is currently active. Only after the previous job completes or fails does re-adding the same `jobId` start a fresh generation; bunqueue evicts the stale terminal record first. So an id such as `report-2026-06-17` is safe to reuse: it is idempotent during one run and starts cleanly after that run becomes terminal. ::: ### With a TTL window The `deduplication` option dedupes within a time window instead of permanently. The `id` field is required: ```typescript // Same id within 1 hour = no new job. After the TTL, a new job is allowed. await queue.add('notification', { userId: '123' }, { deduplication: { id: 'notify-123', ttl: 3600000 } }); ``` ```typescript // Same id within 1 hour = no new job. After the TTL, a new job is allowed. await queue.add('notification', { userId: '123' }, { deduplication: { id: 'notify-123', ttl: 3600000 } }); ``` ```python # Same id within 1 hour = no new job. After the TTL, a new job is allowed. queue.add("notification", {"user_id": "123"}, deduplication={"id": "notify-123", "ttl": 3600000}) ``` ```php // Same id within 1 hour = no new job. After the TTL, a new job is allowed. $queue->add('notification', ['userId' => '123'], [ 'deduplication' => ['id' => 'notify-123', 'ttl' => 3600000], ]); ``` ```go // Same id within 1 hour = no new job. After the TTL, a new job is allowed. queue.Add("notification", map[string]any{"userId": "123"}, bunqueue.JobOptions{ "deduplication": map[string]any{"id": "notify-123", "ttl": 3600000}, }) ``` ```rust use bunqueue_client::{Deduplication, JobOptions}; // Same id within 1 hour = no new job. After the TTL, a new job is allowed. queue.add("notification", data, JobOptions { deduplication: Some(Deduplication { id: "notify-123".into(), ttl: Some(3_600_000), ..Default::default() }), ..Default::default() })?; ``` ```elixir # The Elixir SDK currently supports idempotency through jobId. # TTL deduplication keys are not exposed yet. {:ok, _job} = Bunqueue.Queue.add(queue, "notification", %{user_id: "123"}, jobId: "notify-123" ) ``` Two strategies change what happens when a duplicate arrives: ```typescript // extend: keep the pending job, reset its TTL (debouncing); rejects if the owner is no longer pending (e.g. active) await queue.add('sync-task', { action: 'sync' }, { deduplication: { id: 'sync-task', ttl: 60000, extend: true } }); // replace: remove the pending job, insert a new one with the latest data (last write wins) await queue.add('latest-data', { data: newData }, { deduplication: { id: 'data-job', ttl: 300000, replace: true } }); ``` ```typescript // extend: keep the pending job, reset its TTL (debouncing); rejects if the owner is no longer pending (e.g. active) await queue.add('sync-task', { action: 'sync' }, { deduplication: { id: 'sync-task', ttl: 60000, extend: true } }); // replace: remove the pending job, insert a new one with the latest data (last write wins) await queue.add('latest-data', { data: newData }, { deduplication: { id: 'data-job', ttl: 300000, replace: true } }); ``` ```python # extend: keep the existing job, reset its TTL (debouncing) queue.add("sync-task", {"action": "sync"}, deduplication={"id": "sync-task", "ttl": 60000, "extend": True}) # replace: remove the pending job, insert a new one with the latest data queue.add("latest-data", {"data": new_data}, deduplication={"id": "data-job", "ttl": 300000, "replace": True}) ``` ```php // extend: keep the existing job, reset its TTL (debouncing) $queue->add('sync-task', ['action' => 'sync'], [ 'deduplication' => ['id' => 'sync-task', 'ttl' => 60000, 'extend' => true], ]); // replace: remove the pending job, insert a new one with the latest data $queue->add('latest-data', ['data' => $newData], [ 'deduplication' => ['id' => 'data-job', 'ttl' => 300000, 'replace' => true], ]); ``` ```go // extend: keep the existing job, reset its TTL (debouncing) queue.Add("sync-task", map[string]any{"action": "sync"}, bunqueue.JobOptions{ "deduplication": map[string]any{"id": "sync-task", "ttl": 60000, "extend": true}, }) // replace: remove the pending job, insert a new one with the latest data queue.Add("latest-data", map[string]any{"data": newData}, bunqueue.JobOptions{ "deduplication": map[string]any{"id": "data-job", "ttl": 300000, "replace": true}, }) ``` ```rust // extend: keep the existing job, reset its TTL (debouncing) queue.add("sync-task", data, JobOptions { deduplication: Some(Deduplication { id: "sync-task".into(), ttl: Some(60_000), extend: Some(true), ..Default::default() }), ..Default::default() })?; // replace: remove the pending job, insert a new one with the latest data queue.add("latest-data", new_data, JobOptions { deduplication: Some(Deduplication { id: "data-job".into(), ttl: Some(300_000), replace: Some(true), ..Default::default() }), ..Default::default() })?; ``` ```elixir # TTL extend/replace strategies are not exposed by the Elixir SDK yet. # A stable jobId still collapses repeated adds while the job is unfinished. {:ok, first} = Bunqueue.Queue.add(queue, "sync-task", %{action: "sync"}, jobId: "sync-task") {:ok, duplicate} = Bunqueue.Queue.add(queue, "sync-task", %{action: "sync"}, jobId: "sync-task") true = first.id == duplicate.id ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `id` | `string` | (required) | Unique deduplication key | | `ttl` | `number` | - | Time in ms before the key expires | | `extend` | `boolean` | `false` | Reset TTL on duplicate and keep the pending job; if the key owner is no longer pending, the add rejects with `Duplicate unique_key (extended TTL)` | | `replace` | `boolean` | `false` | Remove pending job, create a new one (new internal id) | :::caution[Replace details] `replace: true` never touches a job that is already processing (active). If both `extend` and `replace` are set, `replace` wins. For durable jobs, replacement is committed as one persistence transition: the superseded pending row is removed before `add()` resolves and cannot reappear after a broker restart. If several same-key replacements are submitted in one bulk add, only the final generation remains runnable and recoverable. When the current owner is active, it keeps running under its existing lease. A durable replacement atomically moves persisted key ownership to the queued successor, so an acknowledgement or failure from the older generation cannot release the new generation's key, including across a restart before that acknowledgement. A non-durable successor retains the normal write-buffer crash window. Replacement is rejected if another live job depends on the owner's id. The broker leaves both jobs and their dependency edge unchanged instead of creating a permanently blocked dependent. Change or remove the dependency explicitly before replacing that owner. On memory/SQLite brokers, `addBulk` uses accepted-prefix semantics: if a later entry fails, earlier accepted jobs remain persisted, counted, and runnable; later entries are not evaluated. PostgreSQL brokers instead roll back the complete batch transaction on an admission error. This applies to TCP clients as well as embedded calls. Use the flow API when the complete graph must commit atomically on every backend. ::: Manage deduplication keys directly in either TypeScript package. In the other SDKs, retain an explicit custom job ID when later lookup is required: ```typescript const jobId = await queue.getDeduplicationJobId('my-unique-key'); // look up await queue.removeDeduplicationKey('my-unique-key'); // allow re-adding const job = await queue.getJob(jobId!); const removed = await job?.removeDeduplicationKey(); // only if this generation owns it ``` ```typescript const jobId = await queue.getDeduplicationJobId('my-unique-key'); // look up await queue.removeDeduplicationKey('my-unique-key'); // allow re-adding const job = await queue.getJob(jobId!); const removed = await job?.removeDeduplicationKey(); // only if this generation owns it ``` ```python job = queue.add("notification", data, job_id="my-custom-job-id") same_job = queue.get_job_by_custom_id("my-custom-job-id") assert same_job is not None and job.id == same_job.id ``` ```php // Explicit custom job ids can be looked up; TTL deduplication keys cannot. $job = $queue->getJobByCustomId('my-custom-job-id'); $jobId = $job?->id(); ``` ```go // Explicit custom job ids can be looked up; TTL deduplication keys cannot. job, err := queue.GetJobByCustomID("my-custom-job-id") if err != nil { return err } ``` ```rust // Explicit custom job ids can be looked up; TTL deduplication keys cannot. let job = queue.get_job_by_custom_id("my-custom-job-id")?; let job_id = job.map(|job| job.id()); ``` ```elixir # Explicit custom job ids can be looked up; TTL deduplication keys cannot. {:ok, job} = Bunqueue.Queue.get_job_by_custom_id(queue, "my-custom-job-id") job_id = if job, do: job.id, else: nil ``` *The shared TypeScript queue lookup/removal methods work in embedded and TCP modes. Job-level removal is generation-safe: a stale job cannot clear a key already transferred to a replacement job. Node.js and Deno support both `getDeduplicationJobId()` and `removeDeduplicationKey()`. A custom `jobId` is a separate value: the custom-ID lookups shown for the other SDKs never resolve `deduplication.id`.* ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # DLQ Operations from the Queue Object Reach the dead letter queue through the bunqueue Queue: configure it, list entries, retry them and purge, plus what changes between embedded and TCP mode. URL: https://bunqueue.dev/guide/queue/dlq/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Dead jobs, from the producer side.

The Queue object carries the DLQ surface too, so the process that produces work can also configure and drain the pile of work that failed.

## DLQ operations The dead letter queue collects jobs that failed permanently (retries exhausted, stalled too often, timed out). Configure how it behaves and act on its entries: ```typescript queue.setDlqConfig({ autoRetry: true, // Periodically re-queue DLQ entries autoRetryInterval: 3600000, // Every hour maxAutoRetries: 3, maxAge: 604800000, // Drop entries older than 7 days maxEntries: 10000, }); // Synchronous snapshots (embedded mode) const entries = queue.getDlq(); const stalledJobs = queue.getDlq({ reason: 'stalled' }); const stats = queue.getDlqStats(); // { total, byReason, pendingRetry, ... } // Authoritative reads (embedded or TCP) const remoteEntries = await queue.getDlqAsync({ reason: 'stalled' }); const remoteStats = await queue.getDlqStatsAsync(); // Act queue.retryDlq(); // Retry all queue.retryDlq('job-123'); // Retry one queue.purgeDlq(); // Clear all const removed = await queue.removeDlqJob('job-123'); // Permanently delete one const retried = await queue.retryDlqByFilterAsync({ reason: 'stalled' }); ``` ```typescript await queue.setDlqConfigAsync({ autoRetry: true, // Periodically re-queue DLQ entries autoRetryInterval: 3600000, // Every hour maxAutoRetries: 3, maxAge: 604800000, // Drop entries older than 7 days maxEntries: 10000, }); // Authoritative broker snapshots const entries = await queue.getDlqAsync(); const stalledJobs = await queue.getDlqAsync({ reason: 'stalled' }); const stats = await queue.getDlqStatsAsync(); // { total, byReason, pendingRetry, ... } // Act await queue.retryDlqAsync(); // Retry all await queue.retryDlqAsync('job-123'); // Retry one await queue.purgeDlqAsync(); // Clear all const removed = await queue.removeDlqJob('job-123'); // Permanently delete one const retried = await queue.retryDlqByFilterAsync({ reason: 'stalled' }); ``` ```python queue.set_dlq_config({ "autoRetry": True, # Periodically re-queue DLQ entries "autoRetryInterval": 3600000, # Every hour "maxAutoRetries": 3, "maxAge": 604800000, # Drop entries older than 7 days "maxEntries": 10000, }) # Inspect entries = queue.get_dlq() # Act queue.retry_dlq() # Retry all queue.retry_dlq("job-123") # Retry one queue.purge_dlq() # Clear all ``` ```php // Inspect $entries = $queue->getDlq(); // Act $queue->retryDlq(); // Retry all $queue->retryDlq('job-123'); // Retry one $queue->purgeDlq(); // Clear all ``` ```go // Inspect entries, _ := queue.GetDlq(0) // 0 = server default count // Act queue.RetryDlq("", 0) // Retry all queue.RetryDlq("job-123", 0) // Retry one queue.PurgeDlq() // Clear all ``` ```rust // Inspect let entries = queue.get_dlq(None)?; // Act queue.retry_dlq(None, None)?; // Retry all queue.retry_dlq(Some("job-123"), None)?; // Retry one queue.purge_dlq()?; // Clear all ``` ```elixir # Inspect {:ok, entries} = Bunqueue.Queue.dlq(queue) # Act {:ok, _count} = Bunqueue.Queue.retry_dlq(queue) # Retry all {:ok, _count} = Bunqueue.Queue.retry_dlq(queue, "job-123") # Retry one {:ok, _count} = Bunqueue.Queue.purge_dlq(queue) # Clear all ``` _DLQ configuration (`setDlqConfig`) is available in Bun, TypeScript, and Python; PHP, Go, Rust, and Elixir can set the same server policy through `PUT /queues/:queue/dlq-config`. Full metadata, stats, and server-side reason filters exist in both TypeScript packages; every SDK can use the HTTP metadata and stats endpoints. Automatic retries preserve their bounded retry chain and failure history across redelivery and durable SQLite/PostgreSQL broker restarts._ :::note[TCP mode] The synchronous `getDlq()` / `getDlqStats()` methods are embedded snapshots, and the synchronous mutation forms are fire-and-forget over TCP. Use `getDlqAsync(filter?)`, `getDlqStatsAsync()`, `retryDlqAsync()`, `retryDlqByFilterAsync(filter)`, and `purgeDlqAsync()` when a remote result or count matters. Full entry metadata and every live `Job` method survive the TCP round trip. `removeDlqJob(id)` and `removeDlqJobAsync(id)` are Promise-based in both modes: they return `false` only when the entry is absent and reject broker errors. `getDlqConfigAsync()` reads the authoritative server config. ::: See [Dead Letter Queue](/guide/dlq/) for the full guide, and [Stall Detection](/guide/stall-detection/) for `setStallConfig()`, which controls when unresponsive jobs are recovered. ## Where to go next | | | | ------------------------------------------------------------------------- | ---------------------------------------------- | | [Dead Letter Queue](/guide/dlq/) | The DLQ guide these operations belong to | | [DLQ Operations](/guide/dlq/operations/) | Filter, retry selectively, check health, purge | | [DLQ Configuration](/guide/dlq/configuration/) | Bound the DLQ by age and by entry count | | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Job Groups: FIFO, Fairness and Backpressure Partition one bunqueue queue by tenant or webhook destination with round-robin FIFO scheduling, per-group depth, rate limits and concurrency. URL: https://bunqueue.dev/guide/queue/job-groups/
guide · queue

Fair streams, one queue.

Put each tenant, webhook destination or customer in a group. bunqueue keeps FIFO ties inside that stream, rotates fairly across streams and enforces backpressure at the broker.

:::note[Job groups, not QueueGroup] This page covers jobs in one queue that use `group: { id }`. The separate [Queue Groups](/guide/queue-group/) helper applies one operation to several different queues. ::: ## Add grouped jobs Both TypeScript packages support the complete group API. Use `bunqueue/client` with Bun, or import the same classes from `bunqueue-client` on Node.js and Deno. The following examples use TCP; embedded mode requires Bun: ```typescript import { Queue, Worker } from 'bunqueue/client'; const queue = new Queue('webhooks'); await queue.add('deliver', { event: 'created' }, { group: { id: 'tenant-a' } }); await queue.add('deliver', { event: 'updated' }, { group: { id: 'tenant-a' } }); await queue.add('deliver', { event: 'created' }, { group: { id: 'tenant-b' } }); ``` When both groups are ready, claims rotate `tenant-a`, `tenant-b`, `tenant-a`. Equal-priority jobs inside each group stay FIFO. Ready jobs without a group are served before grouped jobs. Group IDs may be non-empty strings or safe integers and may contain at most 256 characters. NUL characters are rejected. Numeric IDs are normalized to strings; objects and fractional or unsafe numeric IDs are not coerced. Use `priority` for ordered work inside one group and `maxSize` for atomic producer backpressure. Priority `0` is highest; among positive priorities, a lower number runs first. Values must be integers from `0` through `2,097,151`: ```typescript await queue.add('deliver', payload, { group: { id: 'tenant-a', priority: 1, maxSize: 10_000 }, }); ``` `maxSize` counts pending jobs in waiting, prioritized, and delayed states; an active job no longer consumes that pending-cap slot. The broker performs the count and insert as one admission decision. A full group rejects a single add or the complete atomic FlowProducer graph without leaving partial jobs, and PostgreSQL `addBulk` is one transaction. A memory/SQLite broker's `addBulk`, whether called through TCP or embedded mode, follows the accepted-prefix contract: jobs before the one that hits the cap are admitted and persisted, then the rejection is thrown. ## FIFO does not mean concurrency one FIFO controls which job is claimed first. By default, multiple jobs from the same group may execute at once. Set the broker-side group concurrency when you need serial or bounded work per tenant: ```typescript const worker = new Worker('webhooks', deliverWebhook, { concurrency: 50, group: { concurrency: 2, // at most two active jobs per group }, }); ``` Use `concurrency: 1` for strictly serial execution per group. The ordinary Worker `concurrency` still caps total parallel work in this process. ## Per-group rate limits `group.limit` gives every group its own fixed window, enforced when the broker claims a job: ```typescript const worker = new Worker('webhooks', deliverWebhook, { concurrency: 100, group: { limit: { max: 20, duration: 1000 }, // 20 starts/s for each group }, }); ``` The budget is shared by all Workers. In PostgreSQL multi-broker mode it is stored and consumed transactionally in PostgreSQL, so several brokers still share one exact budget per group. All Workers consuming the queue should use the same group defaults. A Worker that omits `group.limit` has no group rate limiting; one that omits `group.concurrency` has unlimited group concurrency. ## Override one noisy group Local overrides let one tenant use a different policy without changing the Worker defaults: ```typescript await queue.setGroupRateLimit('tenant-a', 5, 1000); await queue.setGroupConcurrency('tenant-a', 1); console.log(await queue.getGroupRateLimit('tenant-a')); // { max: 5, duration: 1000 } console.log(await queue.getGroupConcurrency('tenant-a')); // 1 ``` An override is effective only when the Worker supplied the corresponding default. This matches BullMQ Pro's local group override behavior: a stored rate override does not turn rate limiting on by itself, and a stored concurrency override does not turn group concurrency on by itself. Remove overrides with: ```typescript await queue.removeGroupRateLimit('tenant-a'); await queue.removeGroupConcurrency('tenant-a'); ``` Pause and resume one tenant without pausing the queue: ```typescript await queue.pauseGroup('tenant-a'); await queue.isGroupPaused('tenant-a'); // true await queue.resumeGroup('tenant-a'); ``` Inside a processor, apply an immediate cooldown and return the current delivery to waiting with `await worker.rateLimitGroup(job, 30_000)`. This manual deadline works even when the Worker has no `group.limit` default. The stored `setGroupRateLimit` override above is different: it customizes a configured fixed-window default and has no effect when all Workers omit that default. The broker installs the manual deadline before moving the active delivery back to waiting. If the lease is stale and that move rejects, the Promise rejects but the group cooldown remains active. ## Depth and autoscaling signals ```typescript const tenantDepth = await queue.getGroupJobsCount('tenant-a'); const groupedDepth = await queue.getGroupsJobsCount(); const tenantActive = await queue.getGroupActiveCount('tenant-a'); const retryIn = await queue.getGroupRateLimitTtl('tenant-a', 5); const jobs = await queue.getGroupJobs('tenant-a', 0, 99); const priorities = await queue.getCountsPerPriorityForGroup('tenant-a'); ``` `getGroupJobsCount` and `getGroupsJobsCount` count queued grouped jobs in the waiting, prioritized and delayed states. They exclude active jobs; add `getGroupActiveCount` when an autoscaler needs both backlog and in-flight work. `getGroupRateLimitTtl` returns the remaining window in milliseconds, `0` when the optional `maxJobs` threshold still has room, and `-2` when no live window exists. These are server-authoritative reads. Embedded mode maintains O(1) mirrored depth counters. PostgreSQL queries the durable job/group state, so a read from one broker includes work admitted or claimed through another. ## Persistence and cleanup SQLite persists group-specific rate/concurrency configuration across broker restarts, including pause state; its current fixed-window and manual deadline reset with the process, like the existing SQLite queue-level limiter. PostgreSQL persists configuration, pause/manual deadlines, live fixed-window accounting, immutable grouped admission order and round-robin position. Inactive PostgreSQL scheduler rows are reclaimed only after their jobs, overrides and live rate window are gone. `queue.obliterateAsync()` removes the jobs and all group configuration for that queue. ## Where to go next | Guide | What it covers | | ------------------------------------------------- | -------------------------------------------------------- | | [Rate Limits & Concurrency](/guide/queue/limits/) | Queue-wide limits and how they compose with group limits | | [Worker Concurrency](/guide/worker/concurrency/) | Total Worker parallelism and batch pulling | | [SQLite / PostgreSQL](/guide/databases/) | Share group order and budgets across brokers | | [Job Options](/guide/queue/options/) | All per-job options, including `group` | --- # Queue Rate Limiting and Global Concurrency Cap throughput at the queue level in bunqueue: requests per window rate limits, a global concurrency ceiling across every worker, and how to clear both. URL: https://bunqueue.dev/guide/queue/limits/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

A ceiling on how fast.

Third-party APIs have quotas and databases have limits. These caps live on the queue, so they hold no matter how many workers you start.

## Rate limiting and concurrency ```typescript // Cap parallel processing across ALL workers on this queue queue.setGlobalConcurrency(10); queue.removeGlobalConcurrency(); await queue.setGlobalConcurrencyAsync(10); // same, but waits for the server // Cap throughput: max jobs per window (default window: 1 second) queue.setGlobalRateLimit(100); // max 100 jobs per second queue.setGlobalRateLimit(100, 60_000); // max 100 jobs per minute queue.removeGlobalRateLimit(); await queue.setGlobalRateLimitAsync(100, 60_000); // same, but waits for the server // Temporary throttle to ~1 job/sec; the server clears it after 5s on its own await queue.rateLimit(5000); ``` ```typescript await queue.setGlobalConcurrencyAsync(10); await queue.removeGlobalConcurrencyAsync(); await queue.setGlobalRateLimitAsync(100); // 100 jobs per second await queue.setGlobalRateLimitAsync(100, 60_000); // 100 jobs per minute await queue.removeGlobalRateLimitAsync(); await queue.rateLimit(5000); // Temporary throttle; cleared by the broker const limit = await queue.getGlobalRateLimit(); const ttl = await queue.getRateLimitTtl(); const maxed = await queue.isMaxed(); ``` ```python # Cap parallel processing across ALL workers on this queue queue.set_global_concurrency(10) queue.remove_global_concurrency() # Cap throughput: max jobs per window (default: 1 second) queue.set_global_rate_limit(100) # max 100 jobs per second queue.set_global_rate_limit(100, 60000) # max 100 jobs per minute queue.remove_global_rate_limit() ``` ```php // Cap throughput: max jobs per window (default window: 1 second) $queue->setRateLimit(100); // max 100 jobs per second $queue->setRateLimit(100, 60000); // max 100 jobs per minute $queue->clearRateLimit(); ``` ```go // Cap throughput: max jobs per window (default window: 1 second) queue.SetRateLimit(100) // max 100 jobs per second queue.SetRateLimit(100, bunqueue.RateLimitOptions{DurationMs: 60000}) // max 100 jobs per minute queue.ClearRateLimit() ``` ```rust // Cap throughput: max jobs per window (default window: 1 second) queue.set_rate_limit(100, None, None)?; // max 100 jobs per second queue.set_rate_limit(100, Some(60_000), None)?; // max 100 jobs per minute queue.clear_rate_limit()?; ``` ```elixir # Cap parallel processing across ALL workers on this queue :ok = Bunqueue.Queue.set_concurrency(queue, 10) :ok = Bunqueue.Queue.clear_concurrency(queue) # Cap throughput: max jobs per window (default window: 1 second) :ok = Bunqueue.Queue.set_rate_limit(queue, 100) :ok = Bunqueue.Queue.set_rate_limit(queue, 100, duration: 60_000) :ok = Bunqueue.Queue.clear_rate_limit(queue) ``` *Global concurrency helpers exist in TypeScript, Python, and Elixir. Both TypeScript packages support the temporary `rateLimit(ms)` throttle and broker-authoritative limit getters.* :::note[Read-back semantics] - `rateLimit(ms)` throws on non-positive or non-finite `ms`. The expiry lives on the server, so it also survives your process exiting. - `getGlobalConcurrency()` and `getGlobalRateLimit()` return the live configured values in embedded and TCP modes. - `getRateLimitTtl(maxJobs?)` returns `-2` when no rate limit exists (the PostgreSQL multi-broker backend returns `0` instead and ignores `maxJobs`); otherwise it reports the temporary-limit lifetime or token wait. - `isMaxed()` reports whether the queue's global concurrency slots are all occupied. ::: See [Rate Limiting](/guide/rate-limiting/) for worker-side limiting too. ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Workers, Stats and Metrics from the Queue Inspect a running bunqueue system from the Queue: registered workers, per-queue statistics, windowed metrics and event stream trimming. URL: https://bunqueue.dev/guide/queue/metrics/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Who is connected, and how it is going.

Which workers are registered, what the queue has processed, and the time-windowed counters behind a dashboard.

## Workers and metrics ```typescript const workers = await queue.getWorkers(); // Active workers on this queue const count = await queue.getWorkersCount(); const completedMetrics = await queue.getMetrics('completed', 0, 100); const failedMetrics = await queue.getMetrics('failed', 0, 100); const removed = await queue.trimEvents(1000); // Keep the newest 1,000 events ``` ```typescript const workers = await queue.getWorkers(); // Active workers on this queue const count = await queue.getWorkersCount(); const completedMetrics = await queue.getMetrics('completed', 0, 100); const failedMetrics = await queue.getMetrics('failed', 0, 100); const removed = await queue.trimEvents(1000); // Keep the newest 1,000 events ``` ```python workers = queue.get_workers() # Active workers on this queue count = queue.get_workers_count() stats = queue.get_stats() # Server-wide stats metrics = queue.get_metrics() # Server-wide metrics ``` ```php $workers = $queue->getWorkers(); // Active workers on this queue $stats = $queue->getStats(); // Server-wide stats ``` ```go workers, _ := queue.GetWorkers() // Active workers on this queue stats, _ := queue.GetStats() // Server-wide stats ``` The Rust SDK does not expose queue monitoring helpers yet. Read the broker's Prometheus endpoint instead: ```bash curl --fail http://localhost:6790/prometheus ``` The Elixir SDK does not expose queue monitoring helpers yet. Read the broker's Prometheus endpoint with the standard Erlang HTTP client instead: ```elixir :inets.start() {:ok, {{_, 200, _}, _headers, body}} = :httpc.request(~c"http://localhost:6790/prometheus") ``` *Worker and stats helpers are not yet exposed in Rust and Elixir; scrape the server's Prometheus-text `/prometheus` endpoint instead. The separate `/metrics` endpoint returns JSON operational metrics. `trimEvents()` and windowed `getMetrics(state, start, end)` exist in the Bun `bunqueue` package only.* ## Metric windows `getMetrics(type, start = 0, end = -1)` is queue-scoped in both embedded and TCP mode. It returns: ```typescript interface QueueMetrics { meta: { count: number; // Cumulative terminal jobs for this queue and type prevTS: number; // Timestamp of the most recent terminal job prevCount: number; // Count in its one-minute bucket }; data: number[]; // One-minute buckets, newest first count: number; // Available buckets before pagination } ``` `start` and `end` are inclusive bucket indexes, not timestamps. Index `0` is the newest minute and `end: -1` means through the oldest retained minute. Empty minutes between observed buckets are returned as zero. Unlike BullMQ's Redis collector, bunqueue includes the current partial minute immediately, so one finished job produces a visible data point without waiting for the minute to close. The broker retains at most 20,160 points (two weeks) per queue and state; `meta.count` remains cumulative when older points age out. `trimEvents(maxLength)` operates on the separate lifecycle-event journal. It returns the number of deleted entries, affects only this queue, and is idempotent: calling it again with the same length returns `0`. The journal is persistent in the selected configured backend (SQLite or PostgreSQL), automatically bounded to 10,000 entries per queue, and is deleted together with queue metrics by `obliterateAsync()`. It is ephemeral in memory-only mode. ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # JobOptions Reference Every bunqueue JobOptions field with its type and default: priority, delay, attempts, backoff, timeout, jobId, removeOnComplete, durable and the rest. URL: https://bunqueue.dev/guide/queue/options/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Every option, with its default.

The complete per-job option surface, what each field changes, and what you get when you leave it out.

## Job Options Reference Pass options with each add. The field names follow the host language while the broker receives the same values: ```typescript await queue.add('report', data, { priority: 10, delay: 5000, attempts: 5, jobId: 'report-42', durable: true, }); ``` ```typescript await queue.add('report', data, { priority: 10, delay: 5000, attempts: 5, jobId: 'report-42', durable: true, }); ``` ```python queue.add( "report", data, priority=10, delay=5000, attempts=5, job_id="report-42", durable=True, ) ``` ```php $queue->add('report', $data, [ 'priority' => 10, 'delay' => 5000, 'attempts' => 5, 'jobId' => 'report-42', 'durable' => true, ]); ``` ```go queue.Add("report", data, bunqueue.JobOptions{ "priority": 10, "delay": 5000, "attempts": 5, "jobId": "report-42", "durable": true, }) ``` ```rust queue.add("report", data, JobOptions { priority: Some(10), delay: Some(5000), attempts: Some(5), job_id: Some("report-42".into()), durable: Some(true), ..Default::default() })?; ``` ```elixir {:ok, _job} = Bunqueue.Queue.add(queue, "report", data, priority: 10, delay: 5000, attempts: 5, jobId: "report-42", durable: true ) ``` Python uses snake_case keyword arguments and Rust uses snake_case struct fields. Go uses a `JobOptions` map; PHP and Elixir use the camelCase protocol names shown in the table. | Option | Type | Default | Description | | ------------------ | --------------------------- | ------- | -------------------------------------------------------------------- | | `priority` | `number` | `0` | Higher = processed first | | `delay` | `number` | `0` | Delay in ms before processing | | `attempts` | `number` | `3` | Max total executions, first run included (`3` = 1 run + 2 retries) | | `backoff` | `number \| { type, delay }` | `1000` | Backoff base in ms, or `{ type: 'fixed' \| 'exponential', delay }` | | `timeout` | `number` | - | Processing timeout in ms | | `jobId` | `string` | - | Custom ID for idempotent adds | | `deduplication` | `object` | - | TTL-based dedup (`id`, `ttl`, `extend`, `replace`) | | `removeOnComplete` | `boolean` | `false` | Auto-delete after completion | | `removeOnFail` | `boolean` | `false` | Auto-delete after failure | | `stallTimeout` | `number` | - | Per-job stall timeout override | | `repeat` | `object` | - | Repeating job config (`every`, `pattern`, `limit`) | | `durable` | `boolean` | `false` | SQLite: bypass its write buffer; PostgreSQL is already transactional | | `lifo` | `boolean` | `false` | Process newest first | | `group` | `{ id, priority?, maxSize? }` | - | Assign a [fair job group](/guide/queue/job-groups/), optional 0-first intra-group priority, and atomic pending-depth cap | | `parent` | `{ id, queue }` | - | Parent job reference for [flows](/guide/flow/) | | `stackTraceLimit` | `number` | `10` | Max stacktrace lines stored per failure | | `keepLogs` | `number` | - | BullMQ-compat metadata: stored on the job, not applied automatically. Log trimming happens only when a clear-logs call passes its own `keepLogs` | | `timestamp` | `number` | now | Override the job's creation timestamp (`createdAt`) | | `failParentOnFailure` | `boolean` | `false` | Flow: a terminal child failure fails the parent | | `continueParentOnFailure` | `boolean` | `false` | Flow: parent continues despite this child's terminal failure | | `ignoreDependencyOnFailure` | `boolean` | `false` | Flow: parent ignores this failed child's dependency | | `removeDependencyOnFailure` | `boolean` | `false` | Flow: remove this child from the parent's dependencies on failure | | `sizeLimit` | `number` | - | BullMQ-compat metadata: stored on the job, not enforced by the broker | | `debounce` | `{ id, ttl }` | - | Legacy BullMQ alias: stored on the job, not enforced — use `deduplication` | At the same priority, LIFO jobs run newest-first ahead of FIFO jobs. Priority always remains authoritative, so a lower-priority LIFO job cannot overtake a higher-priority FIFO job. The top-level `priority` rule above applies to ungrouped jobs. For grouped jobs, put priority inside `group`: `group.priority` accepts integers from `0` through `2,097,151`, where `0` is highest and positive values run in ascending order. `group.maxSize` must be a positive safe integer. A full group rejects a single add or an atomic flow admission. PostgreSQL bulk adds are transactional; embedded memory/SQLite bulk adds can retain the jobs accepted before the one that exceeds the cap, then throw. See [group admission semantics](/guide/queue/job-groups/#add-grouped-jobs). Processing `timeout` values are measured from the active transition and use an absolute next-deadline timer. Concurrent jobs retain their individual deadlines. The broker's timeout transition is authoritative: a processor outcome that arrives afterward is ignored for that exact lease generation, without emitting a contradictory local Worker event. A later retry uses a new lease and is not suppressed. `parent` creates a real dependency edge to an existing pending job. The parent moves to `waiting-children`, runs only after every linked child finishes, and exposes child results through `getChildrenValues()`. This works across queues and is committed atomically in both embedded and TCP modes, including `addBulk()`. A missing, active, completed, or failed parent rejects the child; the rejected child is not left in the selected memory, SQLite, or PostgreSQL backend. Use `FlowProducer` when the parent and children must all be created as one new graph. :::tip[Related Guides] - [Worker API](/guide/worker/), process jobs from queues - [Dead Letter Queue](/guide/dlq/), handle failed jobs - [Rate Limiting](/guide/rate-limiting/), control processing rates - [Job Groups](/guide/queue/job-groups/), per-tenant FIFO and backpressure - [Queue Group](/guide/queue-group/), manage multiple queues ::: ## Where to go next | Guide | What it covers | | ------------------------------------------------------------------------- | --------------------------------------------- | | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | --- # Progress, Job Logs and Dependencies Track long-running bunqueue jobs with progress updates and per-job logs, and inspect the parent/child dependency links a flow creates. URL: https://bunqueue.dev/guide/queue/progress/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

What a job is doing right now.

A job that runs for ten minutes should not be a black box. Progress and logs make it observable, and dependency links show how it relates to the rest of a graph.

## Progress, logs, dependencies ```typescript // Progress and logs (also available on the job object inside a processor) await queue.updateJobProgress('job-id', 75); await queue.addJobLog('job-id', 'Processing step 3 completed'); const { logs, count } = await queue.getJobLogs('job-id', 0, 100); // Parent/child flows (see the Flow guide) const childValues = await queue.getChildrenValues('parent-job-id'); const deps = await queue.getJobDependencies('job-id'); const processed = await queue.getDependencies('parent-id', 'processed', 0, 10); // Wait for a job to finish (requires a QueueEvents instance) import { QueueEvents } from 'bunqueue/client'; const queueEvents = new QueueEvents('my-queue', { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }); await queueEvents.waitUntilReady(); const result = await queue.waitJobUntilFinished('job-id', queueEvents, 30000); ``` ```typescript // Progress and logs (also available on the job object inside a processor) await queue.updateJobProgress('job-id', 75); await queue.addJobLog('job-id', 'Processing step 3 completed'); const { logs, count } = await queue.getJobLogs('job-id', 0, 100); // Parent/child flows (see the Flow guide) const childValues = await queue.getChildrenValues('parent-job-id'); const deps = await queue.getJobDependencies('job-id'); const processed = await queue.getDependencies('parent-id', 'processed', 0, 10); // Wait for a job to finish (requires a QueueEvents instance) import { QueueEvents } from 'bunqueue-client'; const queueEvents = new QueueEvents('my-queue', { embedded: false, connection: { host: '127.0.0.1', port: 6789 }, }); await queueEvents.waitUntilReady(); const result = await queue.waitJobUntilFinished('job-id', queueEvents, 30000); ``` ```python # Progress and logs (also available on the job object inside a processor) queue.update_job_progress("job-id", 75) queue.add_job_log("job-id", "Processing step 3 completed") logs = queue.get_job_logs("job-id", 0, 100) # Parent/child flows (see the Flow guide) child_values = queue.get_children_values("parent-job-id") # Wait for a job to finish (raises on timeout or failure) result = queue.wait_for_job("job-id", timeout_ms=30000) ``` ```php // Progress and logs (progress requires an active job) $queue->getJob('job-id')?->updateProgress(75); $queue->addJobLog('job-id', 'Processing step 3 completed'); $logs = $queue->getJobLogs('job-id'); // Parent/child flows (see the Flow guide) $childValues = $queue->getChildrenValues('parent-job-id'); // Wait for a job to finish (throws on timeout or failure) $result = $queue->waitForJob('job-id', 30000); ``` ```go // Progress and logs (job methods; progress requires an active job) job, _ := queue.GetJob("job-id") job.UpdateProgress(75, "") job.Log("Processing step 3 completed", "") logs, _ := queue.GetJobLogs("job-id") // Parent/child flows (see the Flow guide) childValues, _ := queue.GetChildrenValues("parent-job-id") // Wait for a job to finish (errors on timeout or failure) result, _ := queue.WaitForJob("job-id", 30000) ``` ```rust // Progress and logs (job methods; progress requires an active job) if let Some(job) = queue.get_job("job-id")? { job.update_progress(75.0, None)?; job.log("Processing step 3 completed", None)?; } let logs = queue.get_job_logs("job-id")?; // Wait for a job to finish (errors on timeout or failure) let result = queue.wait_for_job("job-id", 30_000)?; ``` ```elixir # Progress and logs (job functions; progress requires an active job) {:ok, job} = Bunqueue.Queue.get_job(queue, "job-id") {:ok, _} = Bunqueue.Job.update_progress(job, 75) {:ok, _} = Bunqueue.Job.log(job, "Processing step 3 completed") {:ok, logs} = Bunqueue.Queue.get_logs(queue, "job-id") # Wait for a job to finish (errors on timeout or failure) {:ok, result} = Bunqueue.Queue.wait_for_job(queue, "job-id", 30_000) ``` *`getJobDependencies`, `getDependencies`, and `QueueEvents` are available in the Bun `bunqueue` package. Bun QueueEvents supports both embedded and TCP brokers; use the same connection and `prefixKey` as the Queue. External SDKs expose `waitForJob` for the common wait-for-result case and can use SSE/WebSocket for live queue events.* Manual state transitions are BullMQ-compatible. Whenever the active job has a lock, `token` is mandatory and must be that job's current worker token in both embedded and TCP mode. Jobs processed without locks remain administratively movable without one: ```typescript await queue.moveJobToCompleted('job-id', { success: true }, token); await queue.moveJobToFailed('job-id', new Error('reason'), token); await queue.moveJobToWait('job-id', token); await queue.moveJobToDelayed('job-id', Date.now() + 60000, token); await queue.moveJobToWaitingChildren('job-id', token); ``` ```typescript await queue.moveJobToCompleted('job-id', { success: true }, token); await queue.moveJobToFailed('job-id', new Error('reason'), token); await queue.moveJobToWait('job-id', token); await queue.moveJobToDelayed('job-id', Date.now() + 60000, token); await queue.moveJobToWaitingChildren('job-id', token); ``` ```python queue.move_job_to_completed("job-id", {"success": True}, token) queue.move_job_to_failed("job-id", RuntimeError("reason"), token) queue.move_job_to_wait("job-id", token) queue.move_job_to_delayed("job-id", 60000, token) # delay in ms ``` ```php $queue->moveJobToFailed('job-id', new \RuntimeException('reason'), $token); ``` ```go queue.MoveJobToFailed("job-id", errors.New("reason"), token) ``` ```rust // Worker::run() completes or fails pulled jobs with the lease token for you. // Queue-level recovery remains available for an already failed job: queue.retry_job("job-id")?; ``` ```elixir # This queue helper has no token parameter; use it only for an unlocked job. :ok = Bunqueue.Queue.move_to_delayed(queue, "job-id", 60_000) # delay in ms :ok = Bunqueue.Queue.retry_job(queue, "job-id") # failed -> waiting ``` *The full token-bound transition set is available in both TypeScript packages. Python supports the transitions except `moveJobToWaitingChildren`; PHP and Go expose `moveJobToFailed`, Elixir exposes `move_to_delayed` and `retry_job`, and Rust intentionally leaves completion/failure acknowledgements to `Worker` while exposing queue-level retry.* ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Querying Jobs: State, Counts and Results Read the queue back: fetch a job by id, list by state, count per state and priority, read results and progress, and understand what each state means. URL: https://bunqueue.dev/guide/queue/querying/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Asking the queue what it holds.

Dashboards, health checks and debugging all come down to the same questions: what is in there, what state is it in, and what did that one job actually return.

## Query jobs ```typescript // One job const job = await queue.getJob('job-id'); const state = await queue.getJobState('job-id'); // 'waiting' | 'prioritized' | 'delayed' | 'active' | 'completed' // | 'failed' | 'waiting-children' | 'unknown' // Counts per state const counts = await queue.getJobCountsAsync(); // { waiting, prioritized, active, completed, failed, delayed, // 'waiting-children', paused } // Lists, filtered by state const failed = await queue.getJobsAsync({ state: 'failed', start: 0, end: 50 }); // Exhaustive traversal; TCP mode transparently drains consecutive pages const everyFailedJob = await queue.getFailedAsync(0, -1); ``` ```typescript // One job const job = await queue.getJob('job-id'); const state = await queue.getJobState('job-id'); // 'waiting' | 'prioritized' | 'delayed' | 'active' | 'completed' // | 'failed' | 'waiting-children' | 'unknown' // Counts per state const counts = await queue.getJobCountsAsync(); // { waiting, prioritized, active, completed, failed, delayed, // 'waiting-children', paused } // Lists, filtered by state const failed = await queue.getJobsAsync({ state: 'failed', start: 0, end: 50 }); // Exhaustive traversal; TCP mode transparently drains consecutive pages const everyFailedJob = await queue.getFailedAsync(0, -1); ``` ```python # One job job = queue.get_job("job-id") # None when missing state = queue.get_state("job-id") # "waiting" | "prioritized" | "delayed" | "active" | "completed" # | "failed" | "waiting-children" # Counts per state counts = queue.get_job_counts() # Lists, filtered by state failed = queue.get_jobs("failed", 0, 50) ``` ```php // One job $job = $queue->getJob('job-id'); // null when missing $state = $queue->getState('job-id'); // 'waiting' | 'prioritized' | 'delayed' | 'active' | 'completed' // | 'failed' | 'waiting-children' // Counts per state $counts = $queue->getJobCounts(); // Lists, filtered by state $failed = $queue->getJobs('failed', 0, 50); ``` ```go // One job job, _ := queue.GetJob("job-id") // nil when missing state, _ := queue.GetState("job-id") // "waiting" | "prioritized" | "delayed" | "active" | "completed" // | "failed" | "waiting-children" // Counts per state counts, _ := queue.GetJobCounts() // Lists, filtered by state failed, _ := queue.GetJobs("failed", 0, 50) ``` ```rust // One job let job = queue.get_job("job-id")?; // None when missing let state = queue.get_state("job-id")?; // Counts per state let counts = queue.get_job_counts()?; // Lists, filtered by state (offset, limit) let failed = queue.get_jobs(Value::from("failed"), 0, 50)?; ``` ```elixir # One job {:ok, job} = Bunqueue.Queue.get_job(queue, "job-id") # nil when missing {:ok, state} = Bunqueue.Queue.get_state(queue, "job-id") # Counts per state {:ok, counts} = Bunqueue.Queue.get_job_counts(queue) # Lists, filtered by state (offset, limit) {:ok, failed} = Bunqueue.Queue.get_jobs(queue, "failed", 0, 50) ``` Most read methods come in two flavors: a sync version that only works in embedded mode (`getJobs()`, `getCountsPerPriority()`, `count()`, `isPaused()`; `getJobCounts()` instead delegates to the async path in TCP mode, returning a Promise of the real server-side counts — await it there) and an async version that works in both modes (`getJobCountsAsync()`, `getJobsAsync()`, `getCountsPerPriorityAsync()`, `countAsync()`, `isPausedAsync()`). Prefer the async ones unless you know you're embedded. Jobs returned by `getJob()` and `getJobsAsync()` reflect the authoritative broker generation in both modes. In particular, `attemptsMade`, `attemptsStarted`, `stalledCounter`, progress, priority, processing/completion timestamps, and their `toJSON()` / `asJSON()` representations are not reset by the TCP query proxy. Per-state shortcuts: ```typescript // Sync (embedded only): getWaiting, getActive, getCompleted, getFailed, getDelayed const waiting = queue.getWaiting(0, 10); // Async (both modes): same names + Async const failed = await queue.getFailedAsync(0, 10); // Counts (async, both modes) const failedCount = await queue.getFailedCount(); // also: getWaitingCount, getActiveCount, getCompletedCount, getDelayedCount // BullMQ-compatible extras const prioritized = await queue.getPrioritized(0, 10); // jobs with priority > 0 const waitingChildren = await queue.getJobsAsync({ state: 'waiting-children', start: 0, end: 10, }); ``` ```typescript // Awaitable state shortcuts work over TCP const waiting = await queue.getWaitingAsync(0, 10); // All state lists have an Async variant const failed = await queue.getFailedAsync(0, 10); // Counts (async, both modes) const failedCount = await queue.getFailedCount(); // also: getWaitingCount, getActiveCount, getCompletedCount, getDelayedCount // BullMQ-compatible extras const prioritized = await queue.getPrioritized(0, 10); // jobs with priority > 0 const waitingChildren = await queue.getJobsAsync({ state: 'waiting-children', start: 0, end: 10, }); ``` ```python # Per-state lists: get_waiting, get_active, get_completed, get_failed, get_delayed waiting = queue.get_waiting(0, 10) failed = queue.get_failed(0, 10) # Counts failed_count = queue.get_failed_count() # also: get_waiting_count, get_active_count, get_completed_count, get_delayed_count # BullMQ-compatible extras prioritized = queue.get_prioritized(0, 10) # jobs with priority > 0 waiting_children = queue.get_waiting_children(0, 10) ``` ```php // These SDKs use the general state-filtered query. $waiting = $queue->getJobs('waiting', 0, 10); $failed = $queue->getJobs('failed', 0, 10); $counts = $queue->getJobCounts(); $failedCount = $counts['failed'] ?? 0; $prioritized = $queue->getJobs('prioritized', 0, 10); $waitingChildren = $queue->getJobs('waiting-children', 0, 10); ``` ```go // These SDKs use the general state-filtered query. waiting, err := queue.GetJobs("waiting", 0, 10) failed, err := queue.GetJobs("failed", 0, 10) counts, err := queue.GetJobCounts() failedCount := counts["failed"] prioritized, err := queue.GetJobs("prioritized", 0, 10) waitingChildren, err := queue.GetJobs("waiting-children", 0, 10) ``` ```rust use bunqueue_client::Value; // Rust takes an offset and a limit. let waiting = queue.get_jobs(Value::from("waiting"), 0, 10)?; let failed = queue.get_jobs(Value::from("failed"), 0, 10)?; let counts = queue.get_job_counts()?; let failed_count = counts.get("failed").copied().unwrap_or(0); let prioritized = queue.get_jobs(Value::from("prioritized"), 0, 10)?; let waiting_children = queue.get_jobs(Value::from("waiting-children"), 0, 10)?; ``` ```elixir # Elixir takes an offset and a limit. {:ok, waiting} = Bunqueue.Queue.get_jobs(queue, "waiting", 0, 10) {:ok, failed} = Bunqueue.Queue.get_jobs(queue, "failed", 0, 10) {:ok, counts} = Bunqueue.Queue.get_job_counts(queue) failed_count = Map.get(counts, "failed", 0) {:ok, prioritized} = Bunqueue.Queue.get_jobs(queue, "prioritized", 0, 10) {:ok, waiting_children} = Bunqueue.Queue.get_jobs(queue, "waiting-children", 0, 10) ``` *Per-state shortcuts are available in TypeScript and Python; in PHP, Go, Rust, and Elixir use `getJobs` with a state filter.* `getPrioritized()` and `getWaitingChildren()` work in embedded and TCP modes. Both use the asynchronous job-query path; waiting children are selected through the dedicated state rather than inferred from job data. In both TypeScript packages, `end: -1` is an explicit exhaustive read for `getJobs`, `getJobsAsync`, every sync/async state shortcut, `getPrioritized`, and `getWaitingChildren`. TCP mode requests 1,000-row pages until exhaustion and removes duplicate IDs defensively; a finite `end` still denotes the exclusive end of one page — except for `getWaitingChildren`, which treats a finite `end` as inclusive (`getWaitingChildren(0, 10)` returns up to 11 jobs). `getJobs[Async]({ asc: false })` reverses the stable createdAt/job-id order before that pagination. Because the broker protocol uses numeric offsets rather than a snapshot cursor, concurrent inserts/removals can shift later pages. Pause mutation or reconcile IDs in application code when a stable snapshot is required. :::note[Two states worth knowing] **Prioritized:** jobs with `priority > 0` report the state `'prioritized'`, not `'waiting'` (BullMQ v5 behavior). Both are pullable; prioritized jobs go first. **Paused:** while a queue is paused, its ready jobs are counted under `paused`, never `waiting` or `prioritized`. On `resume()` each job returns to its logical `waiting` or `prioritized` state. Pause state survives server restarts when persistence is on. ::: Jobs that exhaust their retries move to the dead letter queue (DLQ, a holding area for permanently failed jobs) but remain visible: they are counted by `failed`, returned by `getJob(id)`, and listed by `getFailed()`. ## Where to go next | Guide | What it covers | |---|---| | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [Job Schedulers from the Queue](/guide/queue/schedulers/) | Named repeatable schedules from the queue | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # Job Schedulers from the Queue Create named, updatable repeatable schedules with upsertJobScheduler from the bunqueue Queue, list them, and remove them when they are no longer needed. URL: https://bunqueue.dev/guide/queue/schedulers/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · queue

Recurring work, named.

The managed alternative to a repeat option buried in a job: a named schedule you can update in place and remove by id.

## Job Schedulers (Repeatable Jobs) Named, updatable schedules (the managed alternative to `repeat` in job options): ```typescript await queue.upsertJobScheduler('daily-report', { pattern: '0 9 * * *', // cron pattern // or: every: 3600000, // interval in ms }, { name: 'generate-report', data: { type: 'daily' }, }); const scheduler = await queue.getJobScheduler('daily-report'); const schedulers = await queue.getJobSchedulers(0, 99, true); const count = await queue.getJobSchedulersCount(); await queue.removeJobScheduler('daily-report'); ``` ```typescript await queue.upsertJobScheduler('daily-report', { pattern: '0 9 * * *', // cron pattern // or: every: 3600000, // interval in ms }, { name: 'generate-report', data: { type: 'daily' }, }); const scheduler = await queue.getJobScheduler('daily-report'); const schedulers = await queue.getJobSchedulers(0, 99, true); const count = await queue.getJobSchedulersCount(); await queue.removeJobScheduler('daily-report'); ``` ```python queue.upsert_job_scheduler( "daily-report", {"pattern": "0 9 * * *"}, # or {"every": 3600000} {"name": "generate-report", "data": {"type": "daily"}}, ) scheduler = queue.get_job_scheduler("daily-report") schedulers = queue.get_job_schedulers() count = queue.get_job_schedulers_count() queue.remove_job_scheduler("daily-report") ``` ```php $queue->upsertJobScheduler('daily-report', ['pattern' => '0 9 * * *'], // or ['every' => 3600000] ['name' => 'generate-report', 'data' => ['type' => 'daily']], ); $scheduler = $queue->getJobScheduler('daily-report'); $schedulers = $queue->getJobSchedulers(); $queue->removeJobScheduler('daily-report'); ``` ```go queue.UpsertJobScheduler("daily-report", bunqueue.SchedulerRepeat{Pattern: "0 9 * * *"}, // or EveryMs: 3600000 bunqueue.SchedulerTemplate{ Name: "generate-report", Data: map[string]any{"type": "daily"}, }, ) scheduler, _ := queue.GetJobScheduler("daily-report") schedulers, _ := queue.GetJobSchedulers() queue.RemoveJobScheduler("daily-report") ``` ```rust use bunqueue_client::{SchedulerRepeat, SchedulerTemplate}; queue.upsert_job_scheduler( "daily-report", SchedulerRepeat { pattern: Some("0 9 * * *".into()), ..Default::default() }, SchedulerTemplate { name: Some("generate-report".into()), ..Default::default() }, )?; let scheduler = queue.get_job_scheduler("daily-report")?; queue.remove_job_scheduler("daily-report")?; ``` ```elixir :ok = Bunqueue.Queue.upsert_scheduler(queue, "daily-report", %{pattern: "0 9 * * *"}, # or %{every: 3_600_000} %{name: "generate-report", data: %{type: "daily"}} ) {:ok, scheduler} = Bunqueue.Queue.get_scheduler(queue, "daily-report") {:ok, schedulers} = Bunqueue.Queue.list_schedulers(queue) :ok = Bunqueue.Queue.remove_scheduler(queue, "daily-report") ``` Both TypeScript packages order schedulers by their next execution time. Pagination uses zero-based inclusive `start` and `end` offsets, so `(0, 99)` returns at most 100 entries; `end: -1` means the rest of the list. `asc` defaults to `false`. Schedulers with the same next execution time are ordered deterministically by ID in the same direction. Scheduler IDs are global to the broker. Both TypeScript packages apply `prefixKey` to the ID automatically; the other SDKs do not, so prefix IDs explicitly when applications share a server. TypeScript and Python filter scheduler lists to the current queue. PHP, Go, and Elixir currently return the broker-wide list, so filter its `queue` field in application code. Rust exposes get/remove but no list or count helper yet. ## Where to go next | | | |---|---| | [Cron Jobs](/guide/cron/) | The scheduling guide these schedulers belong to | | [Cron Recipes](/guide/cron/recipes/) | Intervals, timezones, repeat-after-completion | | [Cron Expressions & Options](/guide/cron/reference/) | Every field of a cron pattern, and the options | | [Queue API](/guide/queue/) | Create a queue in embedded or TCP mode | | [Adding Jobs](/guide/queue/adding-jobs/) | add, addBulk, priorities, delays, durability | | [Deduplication and Idempotent Job Adds](/guide/queue/deduplication/) | Idempotent adds, dedup keys, custom job ids | | [Querying Jobs](/guide/queue/querying/) | Fetch jobs, states, counts and results | | [Queue Control and Maintenance](/guide/queue/control/) | Pause, drain, obliterate, clean and repair | | [Progress, Job Logs and Dependencies](/guide/queue/progress/) | Progress, per-job logs and dependencies | | [Queue Rate Limiting and Global Concurrency](/guide/queue/limits/) | Rate limits and global concurrency caps | | [DLQ Operations from the Queue Object](/guide/queue/dlq/) | Failed-job operations from the Queue object | | [Workers, Stats and Metrics from the Queue](/guide/queue/metrics/) | Registered workers, stats and metrics windows | | [Namespaces, Auto-Batching and Store-and-Forward](/guide/queue/advanced/) | Namespaces, auto-batching, store-and-forward | | [JobOptions Reference](/guide/queue/options/) | Every JobOptions field, with defaults | --- # bunqueue 2.9.4 Performance: Comparison Against Ten Previous Releases A controlled native comparison of bunqueue 2.9.4 against the ten preceding releases across realistic Embedded and TCP SQLite job lifecycles. URL: https://bunqueue.dev/guide/version-performance-2-9-4/ import { Aside } from '@astrojs/starlight/components';
release performance · native campaign · 2026-09-03

2.9.4 removes the
completion-path wait.

A controlled comparison against ten preceding releases, using complete durable job lifecycles rather than isolated internal operations. Every sample includes admission, SQLite persistence, delivery, retries, result persistence and terminal-state verification.

10.87× TCP throughput versus 2.9.3 +18.0% Embedded throughput versus 2.9.3 88K measured jobs, zero loss or duplication
## Executive summary bunqueue 2.9.4 is the fastest release in this 11-version campaign in both tested modes: - **TCP on-disk:** 1,465 jobs/s, **10.87× the throughput of 2.9.3**, with median elapsed time reduced from 4,453 ms to 410 ms. - **Embedded on-disk:** 1,469 jobs/s, **18.0% more throughput than 2.9.3** and 5.0% more than the previous Embedded leader, 2.9.2. - **Embedded resource use:** median CPU time fell by 17.5% and median peak RSS by 13.6% versus 2.9.3 for this workload. - **Correctness:** all 88,000 measured jobs reached the expected terminal state exactly once. The workers executed 1,760 planned retry attempts, with no missing or duplicate job completions. The TCP step change is the most important operational result. Earlier workers could wait for a 50 ms ACK fallback on each completion wave when configured batch capacity exceeded the outcomes that could currently reach that batch. Version 2.9.4 tracks the reachable ACK frontier and flushes a partial or final cohort as soon as every reachable outcome is buffered. The wire format, persistence schema and public defaults are unchanged. ## Results at a glance Each value is the median of five measured fresh-process samples after one discarded warm-up per version. “Gain” compares 2.9.4 throughput with the named release; higher is better. ### Embedded SQLite lifecycle | Release | Median elapsed | Median throughput | 2.9.4 gain | Median CPU | Median peak RSS | | --------: | -------------: | ------------------: | ----------: | ---------: | --------------: | | **2.9.4** | **680.56 ms** | **1,469.37 jobs/s** | — | **632 ms** | **92.9 MiB** | | 2.9.3 | 803.20 ms | 1,245.02 jobs/s | **+18.02%** | 766 ms | 107.5 MiB | | 2.9.2 | 714.91 ms | 1,398.78 jobs/s | **+5.05%** | 696 ms | 105.2 MiB | | 2.9.1 | 1,001.24 ms | 998.77 jobs/s | **+47.12%** | 914 ms | 100.7 MiB | | 2.9.0 | 1,001.51 ms | 998.50 jobs/s | **+47.16%** | 912 ms | 100.1 MiB | | 2.8.61 | 990.91 ms | 1,009.17 jobs/s | **+45.60%** | 903 ms | 73.7 MiB | | 2.8.60 | 1,005.82 ms | 994.21 jobs/s | **+47.79%** | 894 ms | 74.2 MiB | | 2.8.59 | 1,002.65 ms | 997.36 jobs/s | **+47.33%** | 901 ms | 74.5 MiB | | 2.8.58 | 1,011.31 ms | 988.82 jobs/s | **+48.60%** | 910 ms | 74.6 MiB | | 2.8.57 | 1,003.38 ms | 996.63 jobs/s | **+47.43%** | 907 ms | 73.2 MiB | | 2.8.56¹ | 997.28 ms | 1,002.73 jobs/s | **+46.54%** | 898 ms | 74.0 MiB | ¹ Version 2.8.56 completed this narrow happy-path workload, but its release is marked **do not use** because of separate packaging and CI failures. Its row is retained only to keep the requested ten-release historical window complete. The measured elapsed-time range for 2.9.4 was 639.55–683.06 ms. For 2.9.3 it was 773.74–829.08 ms, and for 2.9.2 it was 689.07–742.01 ms. The ranges show that the 2.9.4 result is clearly separated from 2.9.3, while the smaller lead over 2.9.2 should be treated more conservatively. ### TCP + SQLite lifecycle | Release | Median elapsed | Median throughput | 2.9.4 ratio | Elapsed range | | --------: | -------------: | ------------------: | ----------: | -------------------: | | **2.9.4** | **409.52 ms** | **1,465.14 jobs/s** | — | 392.26–445.81 ms | | 2.9.3 | 4,453.00 ms | 134.74 jobs/s | **10.87×** | 4,399.61–4,483.33 ms | | 2.9.2 | 4,372.78 ms | 137.21 jobs/s | **10.68×** | 4,342.30–4,396.76 ms | | 2.9.1 | 4,635.26 ms | 129.44 jobs/s | **11.32×** | 4,569.39–4,697.78 ms | | 2.9.0 | 4,627.86 ms | 129.65 jobs/s | **11.30×** | 4,599.78–4,638.30 ms | | 2.8.61 | 4,466.54 ms | 134.33 jobs/s | **10.91×** | 4,425.35–4,489.56 ms | | 2.8.60 | 4,443.06 ms | 135.04 jobs/s | **10.85×** | 4,404.47–4,513.26 ms | | 2.8.59 | 4,423.38 ms | 135.64 jobs/s | **10.80×** | 4,400.58–4,464.23 ms | | 2.8.58 | 4,423.86 ms | 135.63 jobs/s | **10.80×** | 4,366.65–4,465.89 ms | | 2.8.57 | 4,394.49 ms | 136.53 jobs/s | **10.73×** | 4,352.05–4,452.49 ms | | 2.8.56¹ | 4,398.56 ms | 136.41 jobs/s | **10.74×** | 4,385.17–4,468.68 ms | The complete 2.9.4 TCP range is separated from every measured sample of every earlier release. Median elapsed time is 90.8% lower than 2.9.3. This is a specific improvement to low-concurrency completion acknowledgement; it should not be generalized into a claim that every TCP operation is eleven times faster. ## What changed in 2.9.4 ### Completion acknowledgement without the fixed-delay tax The TCP worker now determines how many outcomes can actually reach the pending ACK batch. Full waves still coalesce, but constrained, partial and final waves flush immediately when their reachable frontier is complete. This removes the repeated fallback delay exposed by `concurrency=8` and `batchSize=20` in this campaign while preserving batching under sustained load. ### Amortized O(1) completion evidence Recent-completion tracking previously restarted a `Set` iterator while evicting historical entries. Sustained churn at the retention cap could turn that path effectively quadratic. Version 2.9.4 uses ordered occurrence tokens, a head index and bounded stale-slot compaction, retaining exact FIFO eviction semantics with amortized O(1) work. ### Lower telemetry retention and SQLite overhead The in-memory event journal now retains exact per-queue counts instead of full payload object graphs. SQLite telemetry reuses prepared statements and exact committed retention counts, and only runs retention deletion when a queue actually exceeds its cap. These changes reduce avoidable allocation, object retention and repeated database setup without changing subscriber delivery or terminal metrics. Read the [2.9.4 release notes](/changelog/#294---2026-09-03) for the complete implementation and validation record. ## Realistic workload definition “Realistic” here means a complete, mixed queue lifecycle using public APIs and durable state—not that the synthetic processor models every production application. | Property | Embedded sample | TCP sample | | ------------------- | -------------------------------: | -------------------------------: | | Completed jobs | 1,000 | 600 | | Persistence | Fresh SQLite database | Fresh broker SQLite database | | Payload | 512 bytes | 512 bytes | | Worker concurrency | 8 | 8 | | ACK batch size | 20 | 20 | | Delayed jobs | 5% | 5% | | Jobs retried once | 2% | 2% | | Samples per release | 1 discarded warm-up + 5 measured | 1 discarded warm-up + 5 measured | Jobs were inserted in bulk while the worker was active, used a priority mix, persisted their result and finished only after authoritative terminal state was observed. Every sample used a new process, queue, database and—over TCP—a dynamic port and new broker process. Version order was interlaced between rounds to reduce systematic thermal and time-order bias. ## Test environment and revisions | Property | Value | | ----------------- | -------------------------- | | Host | Apple M1 Max, native arm64 | | Logical CPU cores | 10 | | Memory | 32 GiB | | Operating system | Darwin 25.6.0 | | Runtime | Bun 1.4.0 | | Power | AC | | Release | Git revision | Release | Git revision | | ------: | ------------ | ------: | ------------ | | 2.9.4 | `b83dd7bc` | 2.8.61 | `808f015d` | | 2.9.3 | `3fbfde2c` | 2.8.60 | `52d3fb06` | | 2.9.2 | `c39facb9` | 2.8.59 | `2bb5b95d` | | 2.9.1 | `90856560` | 2.8.58 | `07cbf5cd` | | 2.9.0 | `30eb3a16` | 2.8.57 | `7b6da8c0` | | 2.8.56 | `fcc98904` | | | ## How to interpret the comparison - **The result is release-level evidence.** It measures the exact published revisions; it does not isolate every individual commit as a causal variable. - **2.9.3 is not the Embedded baseline leader.** Its durable history and telemetry work added useful behavior but cost throughput in this small lifecycle. Version 2.9.4 recovers that cost and exceeds 2.9.2 by 5.0%. - **Lower historical RSS is not automatically better scalability.** The 2.8.x releases retain fewer feature and state structures and show roughly 74 MiB peak RSS here. Version 2.9.4 is materially lower than 2.9.3 and 2.9.2, but this campaign does not measure memory slope or long-lived retention. - **RSS is not a leak test.** JavaScriptCore heap snapshots, forced-GC retained object counts and repeated-process checkpoints are required before making a memory-leak claim. ## Scope and limitations The campaign uses five measured samples per cell on one Apple Silicon host. It does not include PostgreSQL, multi-broker contention, WAN latency, large payloads, CPU-heavy handlers, sustained multi-hour retention, confidence intervals or a production storage device. The Embedded and TCP workloads also use different job counts, so compare versions within a mode—not absolute Embedded versus TCP rates. For capacity planning, reproduce the topology with the production payload, handler duration, durability policy, retention settings, network and storage. Use the broader [engineering benchmark methodology](/guide/benchmarks/) for publication-grade campaigns. --- # Worker Concurrency and Batch Pulling Run several bunqueue jobs at once with concurrency, change it at runtime, and cut round-trips on high-volume queues with batch pulling and long polling. URL: https://bunqueue.dev/guide/worker/concurrency/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

More at once, fewer round-trips.

Concurrency decides how many jobs a single worker runs in parallel. Batch pulling decides how many it fetches per request. Together they set the ceiling on what one process can do.

## Process jobs in parallel ```typescript const worker = new Worker('my-queue', processor, { embedded: true, concurrency: 5, // Up to 5 jobs at once (default: 1) }); ``` ```typescript const worker = new Worker('my-queue', processor, { embedded: false, concurrency: 5, // Up to 5 jobs at once (default: 1) }); ``` ```python worker = Worker("my-queue", process, concurrency=5) # default: 4 ``` ```php // The PHP worker is sequential by design: one job at a time. // Scale out by running more worker processes instead: // php worker.php & php worker.php & $worker = new Worker('my-queue', $processor); ``` ```go worker := bunqueue.NewWorker("my-queue", processor, bunqueue.WorkerOptions{ Concurrency: 5, // Up to 5 jobs at once (default: 4) }) ``` ```rust let worker = Worker::new("my-queue", processor, WorkerOptions { concurrency: 5, // Up to 5 jobs at once (default: 4) ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("my-queue", handler, concurrency: 5) # default: 1 ``` You can change concurrency at runtime, without restarting: ```typescript worker.concurrency = 10; // Scale up under load worker.concurrency = 2; // Scale back down (minimum: 1) ``` ```typescript worker.concurrency = 10; // Scale up under load worker.concurrency = 2; // Scale back down (minimum: 1) ``` The Python SDK fixes concurrency at construction. Drain and replace the worker to change it safely: ```python worker.close() worker = Worker("my-queue", process, concurrency=10) ``` PHP workers are sequential. Change process-level parallelism by starting or stopping worker processes under your process supervisor. ```bash php worker.php & php worker.php & ``` The Go SDK fixes concurrency at construction. Stop and replace the worker: ```go worker.Stop() worker.Close() worker = bunqueue.NewWorker("my-queue", processor, bunqueue.WorkerOptions{Concurrency: 10}) ``` The Rust SDK fixes concurrency at construction. Stop and replace the worker: ```rust worker.stop(); worker.close(); let worker = Worker::new("my-queue", processor, WorkerOptions { concurrency: 10, ..Default::default() }); ``` The Elixir SDK fixes concurrency at construction. Stop and replace the worker: ```elixir :ok = Bunqueue.Worker.stop(worker) worker = Bunqueue.Worker.new("my-queue", handler, concurrency: 10) ``` *Both TypeScript packages support runtime concurrency changes. The other SDKs fix `concurrency` at construction.* ## Batch pulling For high-volume queues, pull many jobs per round-trip and long-poll while idle: ```typescript const worker = new Worker('queue', processor, { embedded: true, batchSize: 100, // Request up to 100; free concurrency slots cap the pull pollTimeout: 5000, // Wait up to 5s for jobs (long polling) }); ``` ```typescript const worker = new Worker('queue', processor, { embedded: false, batchSize: 100, // Request up to 100; free concurrency slots cap the pull pollTimeout: 5000, // Wait up to 5s for jobs (long polling) }); ``` ```python worker = Worker( "queue", process, batch_size=100, # Request up to 100; free concurrency slots cap the pull poll_timeout_ms=5000, # Wait up to 5s for jobs (long polling) ) ``` ```php $worker = new Worker('queue', $processor, [ 'batchSize' => 100, // Always requests 100; jobs are leased at once and processed sequentially 'pollTimeoutMs' => 5000, // Wait up to 5s for jobs (long polling) ]); ``` ```go worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{ BatchSize: 100, // Request up to 100; free slots cap the pull PollTimeoutMs: 5000, // Wait up to 5s for jobs (long polling) }) ``` ```rust let worker = Worker::new("queue", processor, WorkerOptions { batch_size: 100, // Request up to 100; free slots cap the pull poll_timeout_ms: 5_000, // Wait up to 5s for jobs (long polling) ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("queue", handler, batch_size: 100, # Request up to 100; free slots cap the pull poll_timeout: 5_000 # Wait up to 5s for jobs (long polling) ) ``` Bulk pushes wake idle long-polling workers immediately, they never wait out the timeout. ## Native processor batches (Bun) `batchSize` above optimizes transport but still invokes the processor once per job. The shared TypeScript `batch` option changes the processor contract: one call owns several independently leased jobs. ```typescript const worker = new Worker( 'webhooks', async (leadingJob) => { const jobs = leadingJob.getBatch?.() ?? [leadingJob]; for (const job of jobs) { try { await deliver(job.data); } catch (error) { job.setAsFailed?.(error instanceof Error ? error : new Error(String(error))); } } return { received: jobs.length }; }, { concurrency: 4, batch: { size: 100, minSize: 10, timeout: 250, groupAffinity: true }, } ); ``` Here `concurrency: 4` means up to four processor invocations, each with at most 100 jobs. `size` is 1..1000; `minSize` defaults to 1 and cannot exceed `size`. When a global Worker `limiter` is present, `minSize` also cannot exceed `limiter.max`; larger maximum batches remain valid and run in bounded chunks. With `minSize` and no positive `timeout`, the Worker waits indefinitely for the minimum. After a positive timeout it runs a partial batch. `groupAffinity` keeps every member on the same job-group ID. Without affinity, any batch that contains grouped jobs starts with the members already available instead of waiting for `minSize`. A Worker `limiter` still counts job starts, not processor calls: a ready batch atomically consumes one slot per member, and a batch waiting for `minSize` consumes no slots. If the processor throws, it is invoked once and the same failure is applied to every member — including members already marked with `setAsFailed()`, whose per-member error only wins when the shared invocation resolves. Cancellation is shared by the processor invocation. Cancelling or timing out any active member aborts the processor context signal, so cooperative processor code can stop the whole batch; every member then follows that shared outcome. The processor's return value completes every member not marked with `setAsFailed(error)`. Each member still has its own token, events, retry budget, and final transition; one selective failure does not fail the rest of the batch. Both TypeScript packages support processor batches; the other SDKs retain transport batch pulling. ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # Worker Error Handling, Retries and Backoff How bunqueue treats a throwing processor: attempts, exponential backoff, per-job timeouts, permanent failure into the DLQ, and errors outside the processor. URL: https://bunqueue.dev/guide/worker/errors/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

When the processor throws.

A failed attempt is not a lost job. bunqueue counts the attempt, waits a widening backoff, tries again, and only after the budget runs out does the job become permanently dead.

## Handle errors Throwing inside the processor fails the current attempt; bunqueue retries with backoff (a growing wait between attempts) until `attempts` is exhausted: ```typescript const worker = new Worker('queue', async (job) => { await riskyOperation(); // Just let errors throw, retries are automatic }, { embedded: true }); worker.on('failed', (job, error) => { console.warn(`Attempt ${job.attemptsMade + 1} failed`, error); }); ``` ```typescript const worker = new Worker('queue', async (job) => { await riskyOperation(); // Just let errors throw, retries are automatic }, { embedded: false }); worker.on('failed', (job, error) => { console.warn(`Attempt ${job.attemptsMade + 1} failed`, error); }); ``` ```python def process(job): risky_operation() # Just let exceptions raise, retries are automatic worker = Worker("queue", process) def on_failed(job, error): final_attempt = job.attempts + 1 >= job.max_attempts if final_attempt: alert_ops(job, error) worker.on("failed", on_failed) ``` ```php $worker = new Worker('queue', function (Bunqueue\Job $job) { riskyOperation(); // Just let exceptions throw, retries are automatic }); $worker->on('failed', function ($job, $error) { // This event reports every failed attempt; inspect the DLQ for terminal jobs. logAttemptFailure($job, $error); }); ``` ```go worker := bunqueue.NewWorker("queue", func(job *bunqueue.Job) (any, error) { return nil, riskyOperation() // Return an error, retries are automatic }, bunqueue.WorkerOptions{}) worker.On("failed", func(args ...any) { job := args[0].(*bunqueue.Job) // This event reports every failed attempt; inspect the DLQ for terminal jobs. logAttemptFailure(job, args[1]) }) ``` ```rust use bunqueue_client::{ProcessError, Worker, WorkerOptions}; let worker = Worker::new( "queue", |job| { risky_operation(&job) .map_err(|error| ProcessError::retryable(error.to_string())) // ProcessError::unrecoverable(...) skips retries -> DLQ }, WorkerOptions::default(), ); ``` ```elixir worker = Bunqueue.Worker.new("queue", fn job -> # Return {:error, reason} (or raise), retries are automatic. # Raising Bunqueue.UnrecoverableError skips retries -> DLQ. risky_operation(job) end) ``` The `failed` listener fires after each broker-confirmed failed attempt, not only after retry exhaustion. The job snapshot was pulled before that failure, so the attempt that just failed is `attemptsMade + 1` (both TypeScript packages), `attempts + 1` (Python). PHP and Go do not expose `maxAttempts` on their Job view; use the queue's DLQ operations for authoritative terminal-failure alerts. Rust and Elixir record per-job outcomes in normal processor control flow and expose transport/retry failures through telemetry; see the [SDK guide](/guide/sdks/#worker-events). A processing timeout can win while user code is still running. In that race, the broker has already recorded the failed attempt. Both TypeScript Workers treat the late processor result or exception as an acknowledged no-op: it emits neither a second `completed`/`failed` event nor a Worker `error`, and a newer retry lease remains free to finish normally. ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # Worker Events: completed, failed, stalled Every event a bunqueue Worker emits, with payload signatures: completed, failed, progress, stalled, active, drained, log and closed, plus how to unsubscribe. URL: https://bunqueue.dev/guide/worker/events/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

What the worker tells you.

Metrics, alerting and audit trails all hang off the same handful of events. Here is each one, when it fires and exactly what it hands your listener.

## React to events ```typescript worker.on('completed', (job, result) => { console.log(`Completed: ${job.id}`, result); }); worker.on('failed', (job, error) => { console.error(`Failed: ${job.id}`, error.message); }); worker.on('progress', (job, progress) => { console.log(`Progress: ${job.id} - ${progress}%`); }); worker.on('error', (error) => console.error(error)); // always attach ``` ```typescript worker.on('completed', (job, result) => { console.log(`Completed: ${job.id}`, result); }); worker.on('failed', (job, error) => { console.error(`Failed: ${job.id}`, error.message); }); worker.on('progress', (job, progress) => { console.log(`Progress: ${job.id} - ${progress}%`); }); worker.on('error', (error) => console.error(error)); // always attach ``` ```python worker.on("completed", lambda job, result: print("completed", job.id)) worker.on("failed", lambda job, err: print("failed", job.id, err)) worker.on("progress", lambda job, progress: print("progress", job.id, progress)) ``` ```php $worker->on('completed', fn ($job, $result) => print("completed {$job->id()}\n")); $worker->on('failed', fn ($job, $err) => print("failed {$job->id()}\n")); $worker->on('error', fn ($err) => print($err->getMessage() . "\n")); ``` ```go worker.On("completed", func(args ...any) { job := args[0].(*bunqueue.Job) log.Printf("completed %s", job.ID()) }) worker.On("error", func(args ...any) { log.Println(args[0]) }) ``` Rust exposes transport and worker-retry telemetry rather than per-job event listeners: ```rust use std::sync::Arc; use bunqueue_client::{ ConnectionOptions, TelemetryCallback, TelemetryEvent, Worker, WorkerOptions, }; let telemetry: TelemetryCallback = Arc::new(|event| match event { TelemetryEvent::WorkerRetry { queue, message, .. } => { eprintln!("{queue}: retrying after {message}"); } TelemetryEvent::Error { operation, message } => { eprintln!("{operation}: {message}"); } _ => {} }); let worker = Worker::new("queue", processor, WorkerOptions { connection: ConnectionOptions { telemetry: Some(telemetry), ..Default::default() }, ..Default::default() }); ``` Elixir exposes connection telemetry; record per-job outcomes in the handler's normal return path: ```elixir telemetry = fn event -> Logger.info("bunqueue", bunqueue: event) end worker = Bunqueue.Worker.new("queue", handler, connection: [event_handler: telemetry] ) ``` *Rust and Elixir telemetry covers connection, command, retry, timeout and error lifecycle, but it does not synthesize `completed`/`failed` listener events. Record per-job outcomes inside the processor. See the [SDK guide](/guide/sdks/#worker-events).* All shared TypeScript client events are fully typed. The complete list (other SDK event sets are listed in the [SDK guide](/guide/sdks/#worker-events)): | Event | Callback Parameters | Description | |-------|-------------------|-------------| | `ready` | `()` | Worker started polling | | `active` | `(job: Job)` | Job started processing | | `completed` | `(job: Job, result: R)` | Job completed successfully | | `failed` | `(job: Job, error: Error)` | Job processing failed | | `progress` | `(job: Job \| null, progress: number)` | Job progress updated | | `stalled` | `(jobId: string, reason: string)` | Job stalled (no heartbeat) | | `drained` | `()` | Queue has no more waiting jobs | | `error` | `(error: Error)` | Worker-level error | | `cancelled` | `({ jobId: string, reason: string })` | Job was cancelled | | `log` | `(job: Job, message: string)` | Log written via `job.log()` | | `closed` | `()` | Worker shut down | For Bun Workers, `completed` and `failed` are broker-authoritative. The Worker emits the local terminal event only after the broker accepts that exact lease generation. If the broker's processing timeout already failed or requeued the job while a processor was still returning, its late ACK/FAIL is an idempotent no-op and no contradictory local terminal event or Worker error is emitted. This rule is identical for Embedded, TCP, manual `job.moveToFailed()`, and `SandboxedWorker` execution. The same ownership rule covers nonterminal processor mutations. After the broker accepts `retry()`, `changeDelay()`, `moveToWait()`, `moveToDelayed()`, `moveToWaitingChildren()`, or `discard()`, the Worker emits no local `completed`/`failed` event for the retired generation and sends no automatic ACK/FAIL. A real asynchronous `discard()` rejection emits exactly one Worker `error`; an authoritative already-absent result is silent. For both TypeScript packages, `stalled` is queue-scoped in embedded and TCP mode. TCP workers receive it through a dedicated authenticated event connection that automatically re-subscribes after reconnect. `skipStalledCheck: true` disables only that listener, never broker-side stall recovery. The event is also emitted when an expired lock exhausts the queue's retry or stall budget; in that case the broker records `stalled` before the terminal `failed` queue event and DLQ transition. Remove a listener with `worker.off('completed', handler)`. ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # The Job Object Inside a Worker Processor What a bunqueue processor receives: job data and metadata, progress updates, per-job logs, lock extension and access to child results from a flow. URL: https://bunqueue.dev/guide/worker/job-object/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Everything the processor gets handed.

The job is more than its payload: attempt counts, timestamps, progress reporting, its own log stream, and the results of any children that ran before it.

## Use the job object Inside the processor you get the full job: ```typescript const worker = new Worker('queue', async (job) => { job.id; // Job ID job.name; // Job name job.data; // Job data (typed if you use Worker) job.attemptsMade; // Attempts consumed so far (0 on the first attempt) job.timestamp; // When the job was created await job.updateProgress(50, 'Halfway done'); // Report progress await job.log('Processing step 1'); // Attach a log line return result; }, { embedded: true }); ``` ```typescript const worker = new Worker('queue', async (job) => { job.id; // Job ID job.name; // Job name job.data; // Job data (typed if you use Worker) job.attemptsMade; // Attempts consumed so far (0 on the first attempt) job.timestamp; // When the job was created await job.updateProgress(50, 'Halfway done'); // Report progress await job.log('Processing step 1'); // Attach a log line return result; }, { embedded: false }); ``` ```python def process(job): job.id # Job ID job.name # Job name job.data # Job data job.attempts # Attempts consumed so far job.created_at # When the job was created (epoch ms) job.update_progress(50, "Halfway done") # Report progress job.log("Processing step 1") # Attach a log line return result ``` ```php $worker = new Worker('queue', function (Bunqueue\Job $job) { $job->id(); // Job ID $job->name(); // Job name $job->data(); // User payload, including any user-owned name key $job->attemptsMade(); // Attempts consumed so far $job->updateProgress(50, 'Halfway done'); // Report progress $job->log('Processing step 1'); // Attach a log line return $result; }); ``` ```go worker := bunqueue.NewWorker("queue", func(job *bunqueue.Job) (any, error) { job.ID() // Job ID job.Name() // Job name job.Data() // User payload, including any user-owned name key job.AttemptsMade() // Attempts consumed so far job.UpdateProgress(50, "Halfway done") // Report progress job.Log("Processing step 1", "") // Attach a log line return result, nil }, bunqueue.WorkerOptions{}) ``` ```rust let worker = Worker::new( "queue", |job| { job.id(); // Job ID job.name(); // Job name job.data(); // User payload, including any user-owned name key job.attempts_made(); // Attempts consumed so far let _ = job.update_progress(50.0, Some("Halfway done")); // Report progress let _ = job.log("Processing step 1", None); // Attach a log line Ok(result) }, WorkerOptions::default(), ); ``` ```elixir worker = Bunqueue.Worker.new("queue", fn job -> job.id # Job ID job.name # Job name job.data # Job data job.attempts_made # Attempts consumed so far Bunqueue.Job.update_progress(job, 50, "Halfway done") # Report progress Bunqueue.Job.log(job, "Processing step 1") # Attach a log line {:ok, result} end) ``` ## Pull and process a job manually Both TypeScript packages can disable the automatic loop and explicitly acquire one job: ```typescript const worker = new Worker<{ value: number }, number>( 'calculations', async (job) => job.data.value * 2, { autorun: false } ); const job = await worker.getNextJob(); if (job) { job.name; // the job name, separate from user data job.data; // { value: number } job.token; // broker lease token when useLocks is enabled await worker.processJobManually(job); } ``` `processJobManually(job)` reuses the lease tracked by `getNextJob()`, so the token argument can be omitted. If you pass an explicit token, it must match the current delivery. ## Native batch members (Bun) When the Worker has `batch: { size, ... }`, the processor is invoked once with the leading job. Read all independently leased members through `getBatch()`: ```typescript const worker = new Worker('imports', async (leadingJob) => { const jobs = leadingJob.getBatch?.() ?? [leadingJob]; for (const job of jobs) { try { await importRow(job.data); } catch (error) { job.setAsFailed?.(error instanceof Error ? error : new Error(String(error))); } } return { processed: jobs.length }; }, { batch: { size: 50, minSize: 10, timeout: 100 } }); ``` `getBatch` and `setAsFailed` are optional in the public Job type because an ordinary one-job delivery does not carry them. Every returned member has both methods. `setAsFailed` records a selective failure for that member after the shared processor invocation completes; all unmarked members receive the processor's common result. ## Ownership-changing methods inside a processor When a Bun processor calls `retry()`, `changeDelay()`, `moveToWait()`, `moveToDelayed()`, or `moveToWaitingChildren()`, the confirmed broker transition consumes that delivery generation. Returning or throwing afterward does not send a second ACK or FAIL and does not emit a contradictory local terminal event. A rejected transition still follows normal processor failure handling. `job.discard()` is synchronous for API compatibility, but the Worker tracks and awaits its broker command internally. Graceful shutdown therefore waits for the discard, repeated calls send one command, and a stale processor's lease token cannot discard a newer active generation. An already-retired job is silent; an actual discard transport or engine failure emits one Worker `error` with `context: 'discard'` and leaves broker recovery in charge. ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # Worker Lifecycle: Pause, Resume, Graceful Shutdown Control a running bunqueue Worker: pause and resume consumption, and close it so in-flight jobs finish and are acknowledged instead of stalling on deploy. URL: https://bunqueue.dev/guide/worker/lifecycle/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Stopping without dropping work.

Deploys are the most common cause of stalled jobs. Closing a worker properly lets in-flight jobs finish and be acknowledged before the process exits.

## Control and shut down ```typescript worker.run(); // Start processing (if created with autorun: false) worker.pause(); // Stop pulling new jobs worker.resume(); // Resume await worker.close(); // Wait for active jobs, then stop await worker.close(true); // Force close immediately ``` ```typescript worker.run(); // Start processing (if created with autorun: false) worker.pause(); // Stop pulling new jobs worker.resume(); // Resume await worker.close(); // Wait for active jobs, then stop await worker.close(true); // Force close immediately ``` ```python worker.run() # Blocking loop (or autorun starts it in the background) worker.pause() # Stop pulling new jobs worker.resume() # Resume worker.close() # Stop pulling, wait for in-flight jobs to drain worker.close(timeout=5) # Bound the wait; the drain continues in the background ``` ```php $worker->run(); // Blocking loop: pull, process, repeat $worker->runOnce(); // Pull and process one batch (cron-friendly) $worker->stop(); // Finish the in-flight job, then return from run() $worker->close(); // Unregister and close the connection ``` ```go worker.Run() // Blocking pull loop worker.Stop() // Stop pulling; in-flight jobs finish worker.Close() // Unregister and close the connection ``` ```rust worker.run()?; // Blocking pull loop; returns after stop() worker.run_once()?; // Pull and process one batch worker.stop(); // Ask the loop to exit worker.close(); // Stop, unregister and close the connection ``` ```elixir Bunqueue.Worker.run(worker) # Blocking pull loop; returns after stop Bunqueue.Worker.run_once(worker) # Pull and process one batch Bunqueue.Worker.stop(worker) # Drain, unregister and close ``` Pausing stops new pulls; it does not interrupt processors already running or release jobs already buffered by the Worker. Lease-renewal and worker-registration heartbeats therefore continue while paused. `resume()` reuses those heartbeat loops, and repeated pause/resume cycles do not create additional timers. `close(true)` stops owning the active delivery immediately; it does not forcibly cancel JavaScript already running inside the processor. If that processor later returns or throws, the Worker discards the late outcome without sending `ACK` or `FAIL`. The broker can therefore recover the unfinished job through disconnect, lock-expiry, or stall handling instead of accepting a result from a closed worker. Use plain `close()` when the current result must be committed before shutdown. Processing timeouts follow the same generation rule during normal operation. The broker owns the deadline and claims the active generation atomically. If a processor returns or throws after that claim, the Worker receives an explicit ignored outcome and suppresses its local `completed`, `failed`, and ACK/FAIL error events. A retry's newer lease is independent and can complete normally. ## Cancel active processors cooperatively (Bun) Every Bun processor receives an `AbortSignal` in its second argument. Cancel one delivery or every active delivery owned by this Worker: ```typescript const worker = new Worker('downloads', async (job, context) => { return await fetch(job.data.url, { signal: context?.signal }); }); worker.cancelJob(jobId, 'request withdrawn'); // false unless this Worker is currently executing that job (pulled-but-not-started jobs return false too) worker.cancelAllJobs('service is shutting down'); worker.isJobCancelled(jobId); // true while its cancelled delivery remains active here ``` Cancellation aborts the signal and emits `cancelled({ jobId, reason })`. A Promise processor must pass that signal to `fetch` or another cancellable API, or check `signal.aborted`; JavaScript cannot forcibly stop a Promise that ignores it. Structural Observable processors are unsubscribed automatically. The resulting processor rejection follows normal failure/retry handling. `close(true)` is different: it relinquishes active delivery ownership and suppresses late outcomes so broker recovery can redeliver. Use cancellation when the processor should observe and handle an abort; use forced close when the process must stop owning work immediately. For a clean process exit, hook your runtime's shutdown signal; in the Bun client, also shut down the shared machinery: ```typescript import { shutdownManager, closeSharedTcpClient } from 'bunqueue/client'; process.on('SIGINT', async () => { await worker.close(); shutdownManager(); // Embedded mode: flush writes, close SQLite closeSharedTcpClient(); // TCP mode: close the shared connection pool process.exit(0); }); ``` ```typescript import { closeSharedTcpClient } from 'bunqueue-client'; process.on('SIGINT', async () => { await worker.close(); closeSharedTcpClient(); // TCP mode: close the shared connection pool process.exit(0); }); ``` ```python try: worker.run() except KeyboardInterrupt: worker.close() # drains in-flight jobs ``` ```php $worker->installSignalHandlers(); // SIGTERM / SIGINT -> graceful stop $worker->run(); ``` ```go go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig worker.Stop() // Run() drains in-flight jobs, then closes }() worker.Run() ``` ```rust // `ctrlc` is the signal-hook crate used by this application. let shutdown_worker = worker.clone(); ctrlc::set_handler(move || shutdown_worker.stop())?; worker.run()?; // returns after the handler calls stop() worker.close(); ``` ```elixir task = Task.async(fn -> Bunqueue.Worker.run(worker) end) receive do :shutdown -> :ok = Bunqueue.Worker.stop(worker) # waits for the active run_once batch Task.await(task, :infinity) end ``` *In Rust and Elixir, calling `worker.stop()` / `Bunqueue.Worker.stop(worker)` from another thread or process makes the blocking run loop drain and return; there is no shared client machinery to tear down.* ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # WorkerOptions Reference Every bunqueue WorkerOptions field with its default: concurrency, batchSize, pollTimeout, heartbeatInterval, lockDuration, connection pooling and prefixKey. URL: https://bunqueue.dev/guide/worker/options/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Every knob, with its default.

The complete option surface for a Worker, what each one changes, and the defaults you get when you leave it alone.

## Options reference ```typescript const worker = new Worker('queue', processor, { embedded: true, concurrency: 5, batchSize: 100, // Pull up to 100 jobs per request pollTimeout: 5000, // Long-poll: wait up to 5s for jobs instead of busy polling limiter: { max: 10, duration: 1000 }, // Max 10 jobs per second }); ``` ```typescript const worker = new Worker('queue', processor, { embedded: false, concurrency: 5, batchSize: 100, // Pull up to 100 jobs per request pollTimeout: 5000, // Long-poll: wait up to 5s for jobs instead of busy polling limiter: { max: 10, duration: 1000 }, // Max 10 jobs per second }); ``` ```python worker = Worker( "queue", processor, concurrency=5, batch_size=100, # Pull up to 100 jobs per request poll_timeout_ms=5000, # Long-poll: wait up to 5s for jobs (default) lock_ttl_ms=30000, # Job lease TTL ack_batch={"max_size": 50, "max_delay_ms": 5}, # Opt-in ACK batching ) ``` ```php $worker = new Worker('queue', $processor, [ 'batchSize' => 100, // Pull up to 100 jobs per request 'pollTimeoutMs' => 5000, // Long-poll: wait up to 5s for jobs (default) 'lockTtlMs' => 30000, // Job lease TTL 'heartbeatIntervalS' => 10.0, // Heartbeats fire between jobs (sequential worker) ]); ``` ```go worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{ Concurrency: 5, BatchSize: 100, // Pull up to 100 jobs per request PollTimeoutMs: 5000, // Long-poll: wait up to 5s for jobs (default) LockTtlMs: 30000, // Job lease TTL HeartbeatIntervalS: 10, // Heartbeats are disabled by default in Go }) ``` ```rust use std::time::Duration; use bunqueue_client::{Worker, WorkerOptions}; let worker = Worker::new("queue", processor, WorkerOptions { concurrency: 5, batch_size: 100, // Pull up to 100 jobs per request poll_timeout_ms: 5_000, // Long-poll: wait up to 5s for jobs (default) lock_ttl_ms: 30_000, // Job lease TTL heartbeat_interval: Some(Duration::from_secs(10)), ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("queue", handler, concurrency: 5, batch_size: 100, # Pull up to 100 jobs per request poll_timeout: 5_000, # Long-poll: wait up to 5s for jobs lock_ttl: 30_000, # Job lease TTL heartbeat_interval: 10_000 ) ``` The table below documents the shared TypeScript Worker options for Bun, Node.js, and Deno. Options for the other SDKs are tabulated in the [SDK guide](/guide/sdks/#worker-options). | Option | Type | Default | Description | | ------------------- | ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `embedded` | `boolean` | `false` | Use in-process mode | | `concurrency` | `number` | `1` | Parallel job processing | | `autorun` | `boolean` | `true` | Start polling automatically | | `heartbeatInterval` | `number` | `10000` | Heartbeat interval in ms (0 = disabled) | | `batchSize` | `number` | `10` | Jobs to pull per batch (max: 1000) | | `batch` | `{ size, minSize?, timeout?, groupAffinity? }` | - | Native batch processor. `job.getBatch()` exposes members and `member.setAsFailed(error)` selectively fails one job. `minSize` waits indefinitely without `timeout`; grouped batches require `groupAffinity` to wait. | | `pollTimeout` | `number` | `0` | Long-poll timeout in ms (max: 30000) | | `useLocks` | `boolean` | `true` | Enable BullMQ-style job locks | | `limiter` | `{ max, duration, groupKey? }` | - | Without `groupKey`: max job starts per rolling window, acquired atomically even with concurrent or manual processing. With `groupKey`: per-group concurrency cap of `max` (jobs grouped by `job.data[groupKey]`, `duration` unused) | | `group` | `{ concurrency?, limit?: { max, duration } }` | unlimited / none | Broker-authoritative per-job-group concurrency and fixed-window rate defaults. See [Job Groups](/guide/queue/job-groups/) | | `lockDuration` | `number` | `30000` | Job lock TTL in ms | | `maxStalledCount` | `number` | `1` | Accepted for BullMQ compatibility, but not applied by the Worker. Configure the broker's per-queue `maxStalls` policy with `Queue.setStallConfig()` or the HTTP API instead | | `skipStalledCheck` | `boolean` | `false` | In embedded or TCP mode, skip only this Worker's subscription to `stalled` notifications. It does not disable broker-side stall detection or recovery | | `skipLockRenewal` | `boolean` | `false` | Suppress the per-job heartbeat timer entirely (no `JobHeartbeat` sent), so both lock renewal and broker-side stall freshness stop; only the worker-registration heartbeat keeps running | | `drainDelay` | `number` | `50` | Delay between polls when the queue is empty (ms) | | `removeOnComplete` | `boolean \| number \| KeepJobs` | `false` | Auto-remove completed jobs. Only `true` is honored; `number` / `{ age?, count? }` are accepted for BullMQ type compatibility but ignored — the job's own `removeOnComplete` option still applies | | `removeOnFail` | `boolean \| number \| KeepJobs` | `false` | Same behavior as `removeOnComplete`: only `true` is honored, other values are ignored and the job-level `removeOnFail` option still applies | | `connection` | `ConnectionOptions` | - | TCP connection (`host`, `port`, `token`, `poolSize`) | | `prefixKey` | `string` | - | Namespace prefix; must match the producing Queue's. See [Namespace Isolation](/guide/queue/advanced/#namespace-isolation-prefixkey) | **Connection pool sizing (TCP):** when `poolSize` is not set, it defaults to `min(concurrency, 8)`. Override it by setting `poolSize` explicitly. | Embedded storage option | Type | Default | Description | | --- | --- | --- | --- | | `dataPath` | `string` | Unset | SQLite path for the process-wide embedded manager. Use the same path as the producing Queue. A conflicting path throws; TCP storage is configured on the server. | If `dataPath` is omitted, the Worker uses the existing embedded manager or the configured data-path environment variables. With neither a path nor a configured manager, embedded storage is memory-only. See [Persistence](/guide/quickstart/#turn-on-persistence). ### Native batch option details `batch.size` must be an integer from 1 through 1000. `minSize` defaults to 1, must be no larger than `size`, and waits indefinitely when `timeout` is omitted or zero. With a global Worker `limiter`, `minSize` must also be no larger than `limiter.max`; `batch.size` may be larger and is processed in bounded chunks. A positive `timeout` allows the available partial batch to start after that many milliseconds. `groupAffinity: true` makes each processor batch homogeneous by group ID; without affinity, batches containing grouped jobs do not wait for `minSize`. With native batching, `concurrency` counts processor invocations rather than individual batch members. The leading job exposes every member through `getBatch()`, and `setAsFailed(error)` marks one member for its own failure and retry transition. A Worker `limiter` counts every member and reserves the whole batch atomically only when it is ready; waiting for `minSize` consumes no rate slots. Cancelling or timing out any member aborts the one shared processor signal. See [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/#native-processor-batches-bun). :::caution[Worker options are not the queue's stall policy] `maxStalledCount` and `skipStalledCheck` do not change when the broker recovers an unresponsive job. Use the queue-level `stallInterval`, `maxStalls`, `gracePeriod`, and `enabled` settings described in [Stall Detection](/guide/stall-detection/). `skipLockRenewal`, by contrast, suppresses this Worker's per-job heartbeats altogether — locks stop being renewed and the broker also stops seeing job heartbeats for stall detection (even with `useLocks: false`) — so long-running jobs can be marked stalled and redelivered. ::: ## Where to go next | Guide | What it covers | | ----------------------------------------------------------------------- | ----------------------------------------------- | | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | --- # SandboxedWorker: Isolated Job Processing The experimental bunqueue SandboxedWorker runs handlers in worker threads on Bun, Node.js, and Deno so CPU-heavy jobs cannot block the main loop. URL: https://bunqueue.dev/guide/worker/sandboxed/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Handlers in their own thread.

CPU-bound work starves an event loop. SandboxedWorker moves the handler into a worker thread so the main thread can keep heartbeating, using the same experimental worker-pool implementation on Bun, Node.js, and Deno.

## SandboxedWorker :::danger[Experimental, not recommended for production] Treat `SandboxedWorker` as an opt-in experimental feature. Pin and test the runtime version you deploy, and prefer a standard `Worker` plus a supervised process pool for production workloads. ::: `SandboxedWorker` runs a processor module in Bun Workers or portable worker threads on Node.js and Deno. The queue and heartbeat loop stay in the parent thread. A per-job timeout terminates a stuck thread, and a crashed thread is restarted only while `autoRestart` is enabled and its restart budget remains. This is execution separation, **not a security sandbox**. Threads share the same OS process and authority; do not use it to run untrusted code, and do not assume an out-of-memory failure is contained to one thread. ## Availability ```typescript import { SandboxedWorker } from 'bunqueue/client'; const worker = new SandboxedWorker('cpu-intensive', { processor: './processor.ts', // Path to processor file concurrency: 4, // 4 parallel worker threads timeout: 60000, // Per-job timeout (default: 30000, 0 = disabled) maxMemory: 256, // compatibility hint; <= 64 enables smol mode }); await worker.start(); ``` ```typescript import { SandboxedWorker } from 'bunqueue-client'; const worker = new SandboxedWorker('cpu-intensive', { processor: './processor.js', // Compiled ESM processor module connection: { host: '127.0.0.1', port: 6789 }, concurrency: 4, timeout: 60_000, }); await worker.start(); ``` Node.js and Deno use the portable worker-thread adapter. The processor must be executable by the host runtime; compile TypeScript to JavaScript when needed. Cloudflare Workers cannot create this local thread pool. The Python SDK does not export `SandboxedWorker`. Delegate the calculation to a `ProcessPoolExecutor`; the normal Worker retains the lease and heartbeat loop: ```python pool = ProcessPoolExecutor(max_workers=4) worker = Worker("cpu-intensive", lambda job: pool.submit(run_cpu, job.data).result(), concurrency=4, heartbeat_interval_s=10.0, lock_ttl_ms=60_000) ``` The PHP SDK does not export `SandboxedWorker`. Its Worker is sequential, so use a supervised child process/service and renew the lease during long waits: ```php $worker = new Worker('cpu-intensive', function (Bunqueue\Job $job) { $job->extendLock(60000); return runInChildProcess($job->data()); }, ['lockTtlMs' => 60000]); ``` The Go SDK does not export `SandboxedWorker`. Processors already run in a bounded goroutine pool and the heartbeat loop is separate: ```go worker := bunqueue.NewWorker("cpu-intensive", processor, bunqueue.WorkerOptions{ Concurrency: 4, LockTtlMs: 60_000, HeartbeatIntervalS: 10, }) ``` The Rust SDK does not export `SandboxedWorker`. Its standard Worker runs processors on worker threads and heartbeats independently: ```rust let worker = Worker::new("cpu-intensive", processor, WorkerOptions { concurrency: 4, lock_ttl_ms: 60_000, heartbeat_interval: Some(Duration::from_secs(10)), ..Default::default() }); ``` The Elixir SDK does not export `SandboxedWorker`. The standard Worker runs each handler in a Task and heartbeats from another process; isolate blocking NIFs in a dirty scheduler or external port: ```elixir worker = Bunqueue.Worker.new("cpu-intensive", handler, concurrency: 4, lock_ttl: 60_000, heartbeat_interval: 10_000) ``` The remaining API is shared by both TypeScript packages. A local thread pool requires Bun, Node.js, or Deno; it is unavailable inside Cloudflare Workers. ## Processor module **Processor file** (`processor.ts`): ```typescript export default async (job: { id: string; data: any; queue: string; attempts: number; parentId?: string; progress: (value: number) => void; log: (message: string) => void; fail: (error: string | Error) => void; }) => { job.progress(50); const result = await heavyComputation(job.data); job.progress(100); return result; }; ``` To connect to a remote server instead of running embedded, pass a `connection` option (`host`, `port`, `token`); otherwise the shared embedded manager is used. ## Lifecycle and local stats ```typescript await worker.start(); worker.isRunning(); const stats = worker.getStats(); // { total, busy, idle, recycled, restarts } await worker.stop(); // Graceful (waits for busy workers) await worker.stop(true); // Force ``` `getStats()` reports pool bookkeeping in the current process. It is not a broker metrics snapshot. ### SandboxedWorker options | Option | Type | Default | Description | |--------|------|---------|-------------| | `processor` | `string` | (required) | Path to processor file | | `concurrency` | `number` | `1` | Parallel worker threads | | `maxMemory` | `number` | `256` | Compatibility hint: values `<= 64` enable Bun's `smol` Worker mode. This implementation does **not** enforce an MB memory limit | | `timeout` | `number` | `30000` | Per-job timeout in ms (0 = disabled) | | `autoRestart` | `boolean` | `true` | Auto-restart crashed threads | | `maxRestarts` | `number` | `10` | Restart budget per thread; the counter increments before the check, so `10` allows 9 actual restarts | | `pollInterval` | `number` | `10` | Sleep in ms when no idle thread is available; job pulls themselves use a fixed 1000 ms broker long-poll | | `heartbeatInterval` | `number` | `5000` (embedded) / `10000` (TCP) | Heartbeat for stall detection and lock renewal; non-positive disables it | | `idleTimeout` | `number` | `0` | Stop the pool after this many idle ms (0 = disabled) | | `idleRecycleMs` | `number` | `30000` | Recycle idle threads after this many ms (0 = disabled) | | `autoStart` | `boolean` | `false` | Restart the pool when new jobs arrive after an idle shutdown | | `autoStartPollMs` | `number` | `5000` | Poll interval while idle-stopped | | `connection` | `ConnectionOptions` | - | TCP connection (omit for embedded) | SandboxedWorker emits eight local events: `ready`, `active`, `completed`, `failed`, `progress`, `log`, `error`, and `closed`. It does **not** emit `stalled`, `drained`, or `cancelled`. `completed`/`failed` fire only after the broker confirms the ACK/FAIL as applied; if the broker reports the job as already finalized no event fires, and an ACK/FAIL transport error emits `error` instead. ### Worker vs SandboxedWorker | Comparison | Worker | SandboxedWorker | |---|--------|-----------------| | **Production ready** | ✅ Stable | ⚠️ Experimental bunqueue implementation | | **I/O-bound tasks** (HTTP, DB, APIs) | ✅ Best choice | Overkill | | **CPU-intensive tasks** | ⚠️ Blocks event loop | ✅ Runs in separate thread | | **Untrusted code** | ❌ Not isolated | ❌ Thread separation is not a security boundary | | **Per-thread memory limit** | ❌ | ❌ `maxMemory` does not enforce one | | **Events** | 11 events | 8 events | | **Concurrency, retries, heartbeats** | ✅ | ✅ Supported through a separate implementation | Most workloads are I/O-bound (API calls, database queries, file operations); for those, `Worker` is the right choice. For CPU-heavy work, see [CPU-Intensive Workers](/guide/cpu-intensive-workers/) for the supported offloading and lease-sizing patterns. :::tip[Related Guides] - [Queue API](/guide/queue/), add and manage jobs - [Stall Detection & Recovery](/guide/stall-detection/), handle unresponsive workers - [Monitoring & Prometheus Metrics](/guide/monitoring/), watch worker performance ::: ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # Heartbeats, Stall Detection and Lock Ownership How a bunqueue job stays alive while it runs: heartbeats, stall recovery when a worker dies, and the lock that stops two workers processing the same job. URL: https://bunqueue.dev/guide/worker/stalls/ import { Tabs, TabItem } from '@astrojs/starlight/components';
guide · worker

Proving the worker is still alive.

A crashed worker cannot tell anyone. Heartbeats let the queue notice, and lease tokens ensure only the current owner can settle a recovered job.

## Heartbeats and stall detection While a job is processing, the worker automatically pings the queue ("I'm still working on this"). That ping is the heartbeat. If a job stops receiving heartbeats, for example because the worker crashed, the queue marks it stalled and recovers it, so no job is silently lost. ```typescript const worker = new Worker('queue', processor, { embedded: true, heartbeatInterval: 5000, // Ping every 5 seconds (ms) }); ``` ```typescript const worker = new Worker('queue', processor, { embedded: false, heartbeatInterval: 5000, // Ping every 5 seconds (ms) }); ``` ```python worker = Worker("queue", process, heartbeat_interval_s=5.0) # 0 disables ``` ```php $worker = new Worker('queue', $processor, [ 'heartbeatIntervalS' => 5.0, // Fires between jobs (sequential worker) ]); ``` ```go worker := bunqueue.NewWorker("queue", processor, bunqueue.WorkerOptions{ HeartbeatIntervalS: 5, // Heartbeats are disabled by default in Go }) ``` ```rust let worker = Worker::new("queue", processor, WorkerOptions { heartbeat_interval: Some(Duration::from_secs(5)), // None disables ..Default::default() }); ``` ```elixir worker = Bunqueue.Worker.new("queue", handler, heartbeat_interval: 5_000) # ms ``` Keep the heartbeat interval shorter than the queue's `stallInterval` to avoid false positives. See [Stall Detection](/guide/stall-detection/). ## Lock-based ownership With `useLocks: true` (the default), each pulled job gets a lock, a temporary claim that says "this worker owns this job". The lock is renewed by heartbeats (`lockDuration` sets its TTL) and must be presented when completing or failing the job. Delivery is exclusive while the lease is valid. After expiry, the broker may redeliver while the stale handler is still running, but its old token can no longer acknowledge the job. A redelivery gets a fresh token and a new local processing generation even when the same Worker instance receives it. Only that current generation is heartbeated and allowed to publish an automatic outcome; completion or cleanup from the stale handler cannot remove the new lease. This is at-least-once processing, so handlers must still be idempotent. Locks matter most in **server mode** with multiple workers. In embedded mode with a single process you can trade the safety for a bit of throughput: ```typescript const worker = new Worker('queue', processor, { embedded: true, useLocks: false, // Rely on stall detection only }); ``` ```typescript const worker = new Worker('queue', processor, { embedded: false, useLocks: false, // Rely on stall detection only }); ``` ```python def process(job): job.extend_lock(60_000) # optional explicit lease extension return handle(job) worker = Worker("queue", process, lock_ttl_ms=30_000) ``` ```php $worker = new Worker('queue', function (Bunqueue\Job $job) { // PHP is sequential, so extend manually before work longer than the TTL. $job->extendLock(60000); return processJob($job); }, ['lockTtlMs' => 30000]); ``` ```go worker := bunqueue.NewWorker("queue", func(job *bunqueue.Job) (any, error) { if err := job.ExtendLock(60_000); err != nil { return nil, err } return processor(job) }, bunqueue.WorkerOptions{LockTtlMs: 30_000}) ``` ```rust let worker = Worker::new("queue", |job| { job.extend_lock(60_000) .map_err(|error| ProcessError::retryable(error.to_string()))?; processor(job) }, WorkerOptions { lock_ttl_ms: 30_000, ..Default::default() }); ``` ```elixir # Elixir always uses lock ownership and renews it automatically. worker = Bunqueue.Worker.new("queue", handler, lock_ttl: 30_000) ``` *The other language SDKs always use lock-based ownership: every pulled job carries a lock token whose TTL is set by `lockTtlMs` / `lock_ttl_ms` / `LockTtlMs` / `lock_ttl`, renewed by heartbeats. A single long-running handler can extend its own lease with `job.extendLock(ms)` (PHP), `job.extend_lock(ms)` (Python, Rust), or `job.ExtendLock(ms)` (Go). The canonical TypeScript Job uses `job.extendLock(token, duration)`.* In both TypeScript packages, locks can also be extended explicitly: `worker.extendJobLocks(jobIds, tokens, duration)`. ## Where to go next | Guide | What it covers | |---|---| | [Worker](/guide/worker/) | Create a worker and process your first job | | [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches | | [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do | | [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest | | [Worker Error Handling, Retries and Backoff](/guide/worker/errors/) | Retries, backoff, timeouts and giving up | | [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work | | [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers | | [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults | --- # Agent SDKs: Claude, OpenAI, Mastra & LangGraph Wrap the Claude Agent SDK, OpenAI Agents SDK, Mastra or LangGraph in a durable saga: journal the session, roll back the tools it called, gate risky turns. URL: https://bunqueue.dev/guide/workflow/agent-sdks/
guide · workflow engine

Your agent SDK, inside a transaction.

Four agent frameworks, one integration shape. Each gives you an excellent agent loop. None gives you a way to undo what that loop did to the outside world. That is the part this page adds.

## Who owns what These SDKs are harnesses: they run the model, dispatch tools, manage context. That is a hard problem and they solve it well. What they do not solve is the problem one layer out, where a single agent turn is one step of a business process that also charges cards, opens pull requests and waits for a person. This page covers the Claude Agent SDK, the OpenAI Agents SDK, Mastra and LangGraph. Read one section and you have read all four, because the seam is identical every time: the agent turn becomes one `.step()`, and that step declares what to do if a later step fails. | | The agent framework owns | bunqueue owns | |---|---|---| | The loop | Model calls, tool dispatch, context | Nothing, it stays out of the way | | Conversation | Session and transcript | Writing that session id to disk | | Effects | Runs your tool functions | The inverse of each one | | Failure | Surfaces the error | Unwinding everything that already happened | | A person in the middle | No | A gate that parks the run for days | The seam is small. An agent turn becomes one `.step()`, and that step declares what to do if a later step fails. ## Claude Agent SDK ```bash bun add @anthropic-ai/claude-agent-sdk ``` `query()` returns an async iterable of messages. Two of them matter for durability: the `init` message carries the session id, and the `result` message carries the final text and the same id. Journal the id and a later turn can continue the same conversation. ```typescript import { query } from '@anthropic-ai/claude-agent-sdk'; async function agentTurn(prompt: string, cwd: string, resume?: string) { const q = query({ prompt, options: { model: 'claude-sonnet-5', cwd, maxTurns: 6, allowedTools: ['Read', 'Write', 'Edit'], permissionMode: 'bypassPermissions', ...(resume ? { resume } : {}), }, }); let sessionId = ''; let text = ''; for await (const message of q) { if (message.type === 'system' && message.subtype === 'init') sessionId = message.session_id; if (message.type === 'result' && message.subtype === 'success') { text = message.result; sessionId = message.session_id; } } return { sessionId, text }; } ``` ### The agent turn is one step of a saga An upgrade bot creates a branch, opens a draft PR, lets the agent edit the code, then waits for a human. If the review says no, the branch and the PR have to go away: ```typescript const flow = new Workflow<{ repo: string }>('dependency-upgrade') .step('create-branch', async (ctx) => { forge.branches.add(`${ctx.input.repo}#bot/upgrade`); return { branch: 'bot/upgrade' }; }, { compensate: async (ctx) => { forge.branches.delete(`${ctx.input.repo}#bot/upgrade`); }, }) .step('open-draft-pr', async () => { forge.prs.set('PR-7', 'open'); return { pr: 'PR-7' }; }, { compensate: async () => { forge.prs.set('PR-7', 'closed'); }, }) .step('agent-edit', async () => { const r = await agentTurn( 'Edit VERSION.txt so it reads exactly "lodash 4.17.21". Then reply with the single word DONE.', cwd, ); return { sessionId: r.sessionId, reply: r.text }; }) .waitFor('code-review', { timeout: 600_000 }) .step('merge', async (ctx) => { const decision = ctx.signals['code-review'] as { approved: boolean }; if (!decision.approved) throw new Error('review rejected the upgrade'); return { merged: true }; }, { retry: 1 }); ``` While the run is parked at the gate it holds no worker slot. Its durable state is in SQLite, with one lightweight timer in memory while a timed gate's process is alive. The review can arrive an hour later, or after the engine has been recreated and recovered following a redeploy. Note that `agent-edit` declares no `compensate`. It edited a working copy on a branch that is about to be deleted, so there is nothing to undo, and a step without an inverse simply carries no compensation record. Only steps that changed something outside the branch need one. ### A real run Unedited output of `scripts/agent-sdks/claude-agent-live.ts`, which executes exactly the code above against the live API:
bun scripts/agent-sdks/claude-agent-live.ts
```text [S1] branch created (1 open) [S1] draft PR opened [S1] agent session 229a3785 replied: DONE [S1] parked at the review gate, PR is open [S1] file on disk: lodash 4.17.21 [S1] compensate: PR closed [S1] compensate: branch deleted [S1] rollbackStatus=completed prs=closed branches=0 [S2] turn 1 session 3c6cd752 → OK [S2] turn 2 session 3c6cd752 → FLAMINGO [S2] same session=true remembered=true ──────────────────────────────────────── PASS S1 rejected review unwinds around a live agent turn PASS S2 journaled session id continues the conversation 2/2 passed ──────────────────────────────────────── ```
The agent really did edit the file, the review really did reject it, and the unwind closed the PR before deleting the branch, which is reverse start order. ### Resume continues the conversation, not just the process The second scenario is the durability claim, made concrete. Turn one is told a codeword. The session id goes through the journal. Turn two reads that id back and asks for the codeword: ```typescript const flow = new Workflow('agent-two-phase') .step('turn-1', async () => { const r = await agentTurn('Remember this codeword: FLAMINGO. Reply with just: OK', cwd); return { sessionId: r.sessionId, text: r.text }; }) .step('turn-2', async (ctx) => { const prev = ctx.steps['turn-1'] as { sessionId: string }; // The id came out of the journal, not out of a variable in this process. const r = await agentTurn('What was the codeword? Reply with just the word.', cwd, prev.sessionId); return { sessionId: r.sessionId, text: r.text }; }); ``` `same session=true remembered=true` in the output above. Because the id is on disk rather than in a closure, the same continuation works after `recover()` picks the run up on a restarted process. ## OpenAI Agents SDK ```bash bun add @openai/agents ``` The shape is the same, with one extra wrinkle worth being careful about: here the side effects happen inside the agent's tools, not in your step body. The step needs to know what its tools did so its compensate handler can undo it. ### Collect what the tools did, return it, undo it ```typescript import { Agent, run, tool } from '@openai/agents'; import { z } from 'zod'; type Effect = { refundId: string }; let effects: Effect[] = []; const issueRefund = tool({ name: 'issue_refund', description: 'Issue a refund to the customer', parameters: z.object({ orderId: z.string(), amount: z.number() }), execute: async ({ orderId, amount }) => { const refundId = ledger.issue(`rf_${orderId}`, amount); effects.push({ refundId }); // remember it return `refund ${refundId} issued`; }, }); const agent = new Agent({ name: 'refund-agent', instructions: 'Refund the customer when the claim is valid.', tools: [issueRefund], }); const flow = new Workflow<{ orderId: string }>('refund-flow') .step('agent-turn', async (ctx) => { effects = []; const result = await run(agent, `Refund order ${ctx.input.orderId}, 49 euro.`); return { text: result.finalOutput, effects: [...effects] }; // journal it }, { compensate: async (ctx) => { const record = ctx.steps['agent-turn'] as { effects: Effect[] } | undefined; for (const e of record?.effects ?? []) ledger.reverse(e.refundId); // undo it }, }) .step('notify-customer', async () => { throw new Error('mailer unreachable'); }); ``` The mailer fails, the unwind runs, and the refund the model decided to issue is reversed. The model chose the action; the workflow owns the consequence. :::caution[Return the effects, do not close over them] The compensate handler reads `ctx.steps['agent-turn']`, which comes from disk. A handler that instead read the `effects` array directly would work in a single process and quietly do nothing after a restart, because that array is empty in the fresh process while the refund is still very much issued. ::: ### The transcript survives the step boundary `run()` returns a `history` you can hand back as the input of the next call, which makes the second step a continuation rather than a restart. Journal it and that continuation survives a crash: ```typescript import { type AgentInputItem, run } from '@openai/agents'; const flow = new Workflow('two-turn-agent') .step('turn-1', async () => { const r = await run(agent, 'What is the capital of France?'); return { text: r.finalOutput, history: r.history }; }) .step('turn-2', async (ctx) => { const prev = ctx.steps['turn-1'] as { history: AgentInputItem[] }; const r = await run(agent, [...prev.history, { role: 'user', content: 'And its population?' }]); return { text: r.finalOutput, turns: r.history.length }; }); ``` This is the same trick as the Claude Agent SDK's session id, with the transcript carried explicitly instead of by reference. ### Approval gates work the same way ```typescript const flow = new Workflow('purge-with-approval') .step('agent-purge', async () => { const r = await run(agent, 'Purge the stale records.'); return { text: r.finalOutput, deleted: [...store.deleted] }; }, { compensate: async (ctx) => { const rec = ctx.steps['agent-purge'] as { deleted: string[] } | undefined; store.restored.push(...(rec?.deleted ?? [])); }, }) .waitFor('operator-approval', { timeout: 86_400_000 }) .step('commit', async (ctx) => { const decision = ctx.signals['operator-approval'] as { approved: boolean }; if (!decision.approved) throw new Error('operator rejected the purge'); return { committed: true }; }, { retry: 1 }); ``` `retry: 1` matters here. A deliberate throw should be believed the first time, not retried five times with backoff before the rollback starts. ## Mastra ```bash bun add @mastra/core @ai-sdk/anthropic ``` Mastra agents take a Vercel AI SDK model, so the model side is whatever you already use. Tools are declared with `createTool`, and the effect collection pattern is the same as the OpenAI SDK. ```typescript import { anthropic } from '@ai-sdk/anthropic'; import { Agent } from '@mastra/core/agent'; import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; let effects: string[] = []; const refundTool = createTool({ id: 'issue_refund', description: 'Issue a refund', inputSchema: z.object({ orderId: z.string(), amount: z.number() }), // Mastra hands the validated input straight to `execute`. It is NOT wrapped in // `{ context }`, which is the shape older examples on the internet still show. execute: async ({ orderId }) => { const id = `rf_${orderId}`; ledger.issued.push(id); effects.push(id); return { refundId: id }; }, }); const agent = new Agent({ id: 'refunder', // required by Mastra's types, distinct from `name` name: 'refunder', instructions: 'Refund the customer.', model: anthropic('claude-sonnet-5'), tools: { refundTool }, }); const flow = new Workflow('mastra-refund') .step('agent-turn', async () => { effects = []; const r = await agent.generate('Refund order ORD-9, 49 euro.'); return { text: r.text, effects: [...effects] }; }, { compensate: async (ctx) => { const rec = ctx.steps['agent-turn'] as { effects: string[] } | undefined; for (const id of rec?.effects ?? []) ledger.reverse(id); }, }) .step('notify', async () => { throw new Error('mailer unreachable'); }); ``` The name advertised to the model is the **key** in the `tools` object, `refundTool` here, not the tool's `id`. Mastra resolves a call made by `id` as well, so both work at runtime, but only the key appears in the tool schema. Worth knowing when you are reading a trace and wondering why `issue_refund` never shows up in it. ## LangGraph ```bash bun add @langchain/langgraph @langchain/core ``` LangGraph is the odd one out, because it is a graph rather than an agent loop, and it has its own checkpointers for graph state. What a checkpointer stores is where the graph got to. What it does not store is how to undo the resources the graph created on the way, which is the gap worth closing. Compile the graph once, then invoke it inside a step: ```typescript import { StateGraph, Annotation, START, END } from '@langchain/langgraph'; const State = Annotation.Root({ tenant: Annotation, resources: Annotation, }); const graph = new StateGraph(State) .addNode('create-db', async (s) => { provisioner.create(`db:${s.tenant}`); return { resources: [...(s.resources ?? []), `db:${s.tenant}`] }; }) .addNode('create-bucket', async (s) => { provisioner.create(`bucket:${s.tenant}`); return { resources: [...(s.resources ?? []), `bucket:${s.tenant}`] }; }) .addEdge(START, 'create-db') .addEdge('create-db', 'create-bucket') .addEdge('create-bucket', END) .compile(); const flow = new Workflow<{ tenant: string }>('langgraph-provision') .step('run-graph', async (ctx) => { const out = await graph.invoke({ tenant: ctx.input.tenant, resources: [] }); return { resources: out.resources }; }, { compensate: async (ctx) => { const rec = ctx.steps['run-graph'] as { resources: string[] } | undefined; // Reverse, because the graph created them in order. for (const r of [...(rec?.resources ?? [])].reverse()) provisioner.destroy(r); }, }) .step('bill', async () => { throw new Error('billing provider rejected the tenant'); }); ``` Because the graph accumulates its resources into state, the step gets the undo list for free. Billing fails and the bucket is destroyed before the database, matching the order they were created in. :::note[The whole graph is one node] A graph invoked this way is a single journal entry, so a crash mid-graph replays the graph from its start rather than from the node it reached. If you need per-node durability, either give LangGraph a checkpointer and resume it yourself inside the step, or lift the nodes up into separate `.step()` calls and let the engine journal each one. ::: ## The trap that catches everyone Whichever SDK you use, the mistake is the same: putting the compensation logic where it can only see process memory. | Wrong | Right | |---|---| | Handler closes over a local array | Handler reads `ctx.steps[...]` | | Effects tracked in a module variable | Effects returned by the step | | Undo keyed on an in-memory counter | Undo keyed on [`ctx.forwardIdempotencyKey`](/guide/workflow/durability/#idempotency-keys) | The first column passes every test you write on your laptop and does nothing at all the first time a pod is replaced mid-run. ## Choosing between them | | Built-in tools | Conversation carried by | Runs as | |---|---|---|---| | Claude Agent SDK | Read, Write, Edit, Bash, Grep, WebSearch | A session id you resume | A subprocess harness | | OpenAI Agents SDK | None, you supply every tool | A `history` array you pass back | In process | | Mastra | None, `createTool` | Threads and memory, or explicit messages | In process | | LangGraph | None, nodes are functions | Graph state, plus optional checkpointers | In process | None of them is a workflow engine and none claims to be. All four drop into a `.step()` the same way. ## What was actually run Nothing on this page is illustrative. | Example | How it was verified | |---|---| | Claude Agent SDK saga and session resume | `scripts/agent-sdks/claude-agent-live.ts`, live API, 2/2 passed, output above | | OpenAI Agents SDK rollback, history, approval | `test/workflow-agent-sdks.test.ts` | | Mastra tool rollback | same file | | LangGraph graph rollback | same file | Every framework in that file is the real package, installed from npm. The agent loops, tool dispatch and argument validation are genuinely theirs. What is substituted is only the model: a scripted `Model` for the OpenAI SDK, and `MockLanguageModelV3` for Mastra. That keeps the suite deterministic and lets it run with no network. LangGraph needs no substitution at all, since its nodes are plain functions. The Claude Agent SDK is the exception. `query()` spawns the Claude Code harness and needs both a network and credentials, so it cannot run in the isolated gate. Its verification is the live script above, whose output is pasted unedited. What the offline suite covers for it is the part that is ours: journaling the session id, and unwinding the effects around the agent turn. Next: [Durability and idempotency keys](/guide/workflow/durability/) for what survives a restart, or [Rollback](/guide/workflow/rollback/) for the unwind rules in full. --- # Durable AI Agents with the Vercel AI SDK Why AI agents need durable execution: stop re-paying for tokens after a crash, roll back the tools an agent called, gate destructive actions behind approval. URL: https://bunqueue.dev/guide/workflow/ai-agents/
guide · workflow engine

Agents that survive the restart.

An agent loop is the least durable thing in your stack. It spends real money, it changes real systems, and by default it keeps all of that in a variable that dies with the process.

## Why an agent needs this Take a normal agent loop. Ten turns, a handful of tool calls, running inside `generateText`. It works, right up until the pod restarts at turn seven. At that moment you have paid for seven model calls, and something on the other side of those tool calls has changed: a database was provisioned, a customer was created, a card was charged. None of it is written down anywhere your process can find after it comes back. So the loop starts again from turn one, pays for those seven calls a second time, and calls the same tools again against systems that already did the work. That is not an edge case. It is a deploy, an OOM kill, a spot instance going away, a `SIGTERM` during a rolling update.
ten turn agent, killed at turn 7 ✕ process killed
in memory
journaled
The top track loses every turn and pays for all ten again. The bottom track keeps the six turns it had already written down, and only turn seven runs a second time.
Three things go wrong, and they are separate problems: | | What breaks | What it costs | |---|---|---| | **1** | The transcript lives in memory | The agent forgets everything and starts over | | **2** | Completed turns are not recorded | You pay for the same tokens twice | | **3** | Tool side effects have no inverse | Two databases, two charges, one order | A durable workflow engine fixes all three, and it fixes them with the same mechanism: **write down what happened, one step at a time, before moving on.** ## What it looks like in practice These numbers come from `scripts/ai-sdk/saga-live-e2e.ts`, which runs against the live Claude API, kills its own process with a real `SIGKILL`, and asserts against an external SQLite database that outlives the crash. **Tokens are paid once, not twice.** The child process calls the model, the plan is journaled, then the process is killed before it can act on it. A second process calls `recover()`. The external call log shows the model was called **one time**. **An agent killed at turn 7 resumes at turn 7.** Loop iterations are memoised: completed turns are skipped, and only the turn that was actually interrupted runs again. Before this existed, a four turn loop interrupted once executed seven turns in total. Now it executes five, and the two extra are the genuinely unfinished turn plus the one that finishes the job. **Whatever the model chose gets undone in reverse.** In the live run, Claude picked three tools on its own, with its own arguments. A later step failed. The engine destroyed all three, in the exact reverse of the order it had picked them, without knowing the plan in advance.
the model picks, the engine unwinds in reverse reverse start order
provision_database provision_bucket provision_search_index
Provisioned left to right, destroyed right to left. The order is taken from the journal, not from timing, so two identical runs unwind identically even when the parallel steps finish in a different order.
### A real run, not a diagram This is the unedited output of `bun scripts/ai-sdk/saga-live-e2e.ts` against the live API. Claude chose the tools, including `shards: 3`, which nothing in the prompt asked for.
bun scripts/ai-sdk/saga-live-e2e.ts
```text model chose 3 tool call(s): - provision_database({"region":"eu-central"}) - provision_bucket({"region":"eu-central"}) - provision_search_index({"shards":3}) compensation:started apply:2 compensation:completed apply:2 compensation:started apply:1 compensation:completed apply:1 compensation:started apply:0 compensation:completed apply:0 state failed failureReason compliance verification rejected the tenant rollbackStatus completed call log: provision:provision_database-0 provision:provision_bucket-1 provision:provision_search_index-2 destroy:provision_search_index-2 destroy:provision_bucket-1 destroy:provision_database-0 still live: [] PASS: provisioned 3, rolled back 3 ```
## Let the model decide, let the workflow own the effects The trick is one line: define your tools **without** `execute`. The SDK then hands back the model's intent instead of running it, and you turn each intended call into a workflow step that has an inverse. ```bash bun add bunqueue ai @ai-sdk/anthropic ``` ```typescript import { anthropic } from '@ai-sdk/anthropic'; import { generateText, stepCountIs, tool } from 'ai'; import { Engine, Workflow } from 'bunqueue/workflow'; import { z } from 'zod'; const provisionTools = { provision_database: tool({ description: 'Provision a Postgres database for the tenant.', inputSchema: z.object({ region: z.string() }), }), provision_bucket: tool({ description: 'Provision an object storage bucket.', inputSchema: z.object({ region: z.string() }), }), }; const agentSaga = new Workflow<{ tenant: string }>('provision-tenant') .step('plan', async (ctx) => { const result = await generateText({ model: anthropic('claude-sonnet-5'), tools: provisionTools, stopWhen: stepCountIs(1), messages: [{ role: 'user', content: `Provision infrastructure for ${ctx.input.tenant}.` }], }); return { planned: result.toolCalls.map((c) => ({ name: c.toolName, args: c.input })) }; }, { retry: 2, timeout: 90_000 }) // One compensatable unit of work per tool call the MODEL chose. .forEach( (ctx) => ctx.steps.plan.planned, 'apply', async (ctx) => { const call = ctx.steps.__item as { name: string; args: unknown }; return cloud.create(call.name, call.args, { idempotencyKey: ctx.idempotencyKey }); }, { compensate: async (ctx) => { const call = ctx.steps.__item as { name: string }; await cloud.destroy(call.name, { idempotencyKey: ctx.idempotencyKey }); }, }, ) .step('verify', async (ctx) => compliance.check(ctx.input.tenant), { retry: 1 }) .pivot() .step('send-welcome-email', async (ctx) => mailer.welcome(ctx.input.tenant)); ``` The agent decides **what** to do. The workflow owns **the effects**. That separation is the whole idea, and it is what makes a non deterministic planner safe to run against production systems. Read the last two lines carefully. `.pivot()` marks the point of no return: once the welcome email goes out, a later failure must not release the subdomain the customer has already been told about.
a failure after the pivot undoes nothing committed
reserve-subdomain charge-setup-fee pivot send-welcome-email activate ✕
Nothing is struck through. Past the pivot the saga is committed, so the only correct recovery is forward: retry, alert, fix by hand. Releasing the subdomain of a customer who has already been welcomed would be the worse outcome.
## Durable agent turns For a real multi turn loop, drive it yourself: one turn per step, so each turn is written down before the next one starts. ```typescript const MAX_TURNS = 10; const agent = new Workflow<{ task: string }>('agent').doUntil( (_ctx, iteration) => iteration >= MAX_TURNS, (w) => w.step('turn', async (ctx) => { const prior: unknown[] = []; for (let i = 0; ctx.steps[`turn:${i}`]; i++) { prior.push(...(ctx.steps[`turn:${i}`] as { messages: unknown[] }).messages); } const result = await generateText({ model: anthropic('claude-sonnet-5'), tools, stopWhen: stepCountIs(1), messages: [ { role: 'user', content: ctx.input.task }, ...prior, // A restored transcript always ends with an assistant message, which Claude // rejects. Re-open the floor each turn. ...(prior.length > 0 ? [{ role: 'user' as const, content: 'Continue.' }] : []), ], }); return { messages: result.response.messages }; }), { maxIterations: 20 }, ); ``` The transcript is rebuilt from `turn:0`, `turn:1`, `turn:2` and so on, which are rows in SQLite, not entries in an array that vanishes with the process. That is the difference between an agent that remembers and an agent that only appears to. :::tip[Why not just `stopWhen: stepCountIs(20)`?] Letting the SDK drive its whole loop inside one step works, and it keeps the full history in memory. But the entire run is then **one** journal entry, so a crash halfway loses all of it. Splitting the loop into steps is exactly what buys the resume. ::: ## Human approval before something destructive ```typescript .step('propose', async (ctx) => askModelWhatToDelete(ctx.input), { compensate: async () => discardPlan(), }) .waitFor('human-approval', { timeout: 86_400_000 }) .step('execute', async (ctx) => { const decision = ctx.signals['human-approval'] as { approved: boolean }; if (!decision.approved) throw new Error('operator rejected the action'); return performDeletion(ctx.steps.propose); }, { retry: 1 }) ```
the run parks until a person answers durable pause
propose waitFor human-approval ← signal({ approved: true }) execute
While it is parked the run holds no worker slot; its durable state is a row, and a timed gate has one lightweight timer while the process is alive. The approval can arrive minutes later or after a redeploy, once the engine is available again.
The run parks. It stops occupying a worker, its state is on disk, and it can sit there for a day. A signal accepted before a crash stays durable. The API is in-process, so after a full outage you first recreate the engine, register the definition and recover; only then can the service accept a new approval. A rejection is an abort, not a completion, so throwing here unwinds everything the agent did before the gate. ## Every action has a stable identity `ctx.idempotencyKey` is the same string across every retry of a step, and the same across a crash and resume. Pass it to a provider and a repeat lands on the same operation instead of creating a new one. ```typescript await stripe.charges.create({ amount }, { idempotencyKey: ctx.idempotencyKey }); ``` This is where most agent implementations quietly lose money. Derive the key from the attempt number and every retry asks for a brand new charge. Derive it from the step, as the engine does, and the provider deduplicates it for you. Compensate handlers additionally receive `ctx.forwardIdempotencyKey`, the key the forward call used, so a rollback can ask the provider "did this actually happen?" when the model call succeeded but the response never came back. ## When the rollback itself fails A refund gets refused. A cloud API is down. The engine does not pretend the unwind succeeded, and it does not carry on undoing things whose dependencies are still standing. The run parks in `compensation-stuck`, and you get two fields that answer two different questions: ```typescript exec?.failureReason; // why the run failed exec?.rollbackStatus; // 'stuck', what the engine did afterwards await engine.resumeCompensation(run.id); // fixed it, finish the unwind await engine.abandonCompensation(run.id); // accept a partial rollback, explicitly ``` "The provisioning failed" and "the cleanup never ran" need different alerts. An agent platform that collapses them into one status cannot tell you which one woke you up. ## What to keep in mind | | | |---|---| | Pass `ctx.idempotencyKey` to every provider call | Stable across retries and crash resume, so a repeat is absorbed rather than duplicated | | Give destructive tools a `compensate` | The model's choices are non deterministic, the rollback does not have to be | | Put irreversible actions after `.pivot()` | An email cannot be unsent, so nothing before it should be undone either | | Raise `timeout` on model steps | The default is 30s, and a multi turn call routinely exceeds it | | Keep tool bodies idempotent | Recovery is at least once | | Set loop length by iteration, not by the model | `(_ctx, i) => i >= N` is reproducible, "until the model says done" is not | ## Verified against the live API `scripts/ai-sdk/saga-live-e2e.ts` runs eight scenarios against the real Claude API, not a mock: | | | |---|---| | S1 | Happy path across the pivot, nothing rolled back | | S2 | Failure before the pivot, unwound in exact reverse of the model's own choices | | S3 | Failure after the pivot, zero compensations, work stands | | S4 | Refused rollback, run parks, operator resumes, books balance | | S5 | Failure after a real API call, key identical across retries | | S6 | Real `SIGKILL` after tokens were paid, resume does not call the model again | | S7 | Human approval rejected, agent work unwound | | S8 | Multi turn loop, transcript grows across turns | ```bash ANTHROPIC_API_KEY=... bun scripts/ai-sdk/saga-live-e2e.ts ``` The offline equivalents run in CI against a mock model, so a regression is caught without spending tokens. ## Using a different agent SDK Everything on this page is written with the Vercel AI SDK, because it is the smallest surface to read. The pattern is not tied to it. For the same saga built on the **Claude Agent SDK** and the **OpenAI Agents SDK**, including how to journal an agent session id and how to roll back tools the model chose to call, see [Claude & OpenAI Agent SDKs](/guide/workflow/agent-sdks/). ## Where it differs from Temporal Temporal resumes on its own; here `recover()` is an explicit call you make at startup. Control-flow choices and completed inner records are journaled, so loops resume at the interrupted iteration, branches retain their selected path, and completed parallel siblings short-circuit. Work left `running` still has an unknown external outcome and can replay. What you get in exchange: no cluster, no control plane, no separate service to operate. SQLite, inside your own process, one `bun add` away. --- # Workflow Engine API Reference Every method, event type and field of the bunqueue workflow engine: Workflow builder, Engine facade, execution shape, event catalogue and known limitations. URL: https://bunqueue.dev/guide/workflow/api/
guide · workflow engine

Every method, every field.

The builder, the engine facade, the execution shape, the fifteen event types, and an honest list of what this engine does not do.

## `Workflow` The builder. Pure data: it performs no work and touches nothing until an `Engine` runs it. Each method returns a re-typed builder so step results accumulate into `TSteps`. `new Workflow(name, { revision? })` defaults `revision` to `"1"`. Registration seals the graph; bump the revision when handler semantics change without a structural graph change. | Method | Notes | |---|---| | `step(name, handler, options?)` | `options`: `retry` (3), `timeout` (30000 ms), `compensate`, `inputSchema`, `outputSchema`. Schema `parse()` output is used, so coercion applies. `retry` counts ATTEMPTS, so `1` means one try with no retry, and anything below `1` or non-integer throws where it is written | | `branch(condition)` | Must be followed by `path()` | | `path(name, builder)` | Steps only, other node types are rejected | | `parallel(builder)` | Requires at least one step | | `subWorkflow(name, inputMapper, options?)` | Result under `ctx.steps['sub:']`; `options.timeout` defaults to 300000 ms and `options.pollInterval` to 100 ms | | `waitFor(event, { timeout? })` | Parks the run. One gate per event name, and omit `timeout` to wait indefinitely: `0` is a deadline already past | | `doUntil(condition, builder, { maxIterations? })` | Default 100 | | `doWhile(condition, builder, { maxIterations? })` | Default 100 | | `forEach(items, name, handler, options?)` | `items` must return an array; anything else throws. Default `maxIterations` 1000 | | `map(name, fn)` | Transform intended to be pure; no retry or timeout, but full running/completed/failed records and step events | | `pivot()` | Point of no return; nothing is compensated once passed | `register()` refuses a definition that could not behave as written: - duplicate step names - declaring one branch path name twice - a step name colliding with a loop's `name:index` namespace - user step names beginning with reserved `__` or `sub:` prefixes - two `waitFor` gates on the same event, since one signal would open both - a `waitFor` with an empty event name, or one named `__proto__`, which cannot be stored as a signal key ## `Engine` ```typescript const engine = new Engine({ embedded: true, // in-process (or connection: { port: 6789 } for TCP) dataPath: './data/wf.db', // SQLite path, omit and nothing persists concurrency: 10, // concurrent workflow-node jobs (default: 5) queueName: '__wf:steps', // internal queue name (default) onEvent: (event) => {}, // global listener }); ``` | Method | Returns | Description | |---|---|---| | `register(workflow)` | `this` | Register a definition | | `start(name, input?)` | `Promise<{ id, workflowName }>` | Start a run | | `getExecution(id)` | `Execution \| null` | Full state by id | | `listExecutions(name?, state?, options?)` | `Execution[]` | Filtered page; `options` is `{ limit?: 1..1000, offset?: number }`, default 100 | | `signal(id, event, payload?)` | `Promise` | First delivery wins. A duplicate cannot replace its payload and throws; empty and `__proto__` event names are invalid | | `recover()` | `Promise` | Resume orphaned runs after a restart | | `resumeCompensation(id)` | `Promise` | Retry the handler that parked a `compensation-stuck` run | | `abandonCompensation(id)` | `Promise` | Accept a partial rollback; the rest are recorded as skipped | | `on(type, cb)` / `onAny(cb)` | `this` | Subscribe (`off` / `offAny` to detach) | | `subscribe(id, cb)` | `() => void` | Follow one run; returns unsubscribe | | `cleanup(maxAgeMs, states?)` | `number` | Delete old executions | | `archive(maxAgeMs, states?)` | `number` | Move to the archive table, max 1000 per call | | `getArchivedCount()` | `number` | Archived row count | | `close(force?)` | `Promise` | Shut down engine, queue and worker | :::caution[`close()` does not end the process] Background maintenance timers are process-wide. Call `shutdownManager()` from `bunqueue/client` after `close()` in a script that must terminate. ::: ## `Execution` ```typescript { id: string; workflowName: string; state: ExecutionState; input: unknown; steps: Record; currentNodeIndex: number; resolvedSteps?: string[]; decisions?: Record; // journaled control-flow choices definitionHash?: string; // sealed graph + explicit revision signals: Record; rollbackStatus?: RollbackStatus; failureReason?: string; committedAt?: number; // node index where .pivot() committed parentExecutionId?: string; // child workflow ownership createdAt: number; updatedAt: number; } ``` ### `ExecutionState` | Value | Meaning | |---|---| | `running` | Working through nodes | | `waiting` | Parked at a `waitFor` | | `compensating` | Unwinding | | `completed` | Finished successfully | | `failed` | Terminal; the unwind finished or was not applicable | | `compensation-stuck` | **Non-terminal.** A reversal failed; awaiting an operator | ### `RollbackStatus` Independent of `failureReason`: `completed`, `not-applicable`, `stuck`. The field is **absent** until an unwind is attempted, so test for `undefined` rather than for a "nothing happened yet" value. ### `StepRecord` ```typescript { status: 'pending' | 'running' | 'completed' | 'failed'; result?: unknown; error?: string; startedAt?: number; completedAt?: number; attempts?: number; idempotencyKey?: string; // run:step#occurrence:direction occurrence?: number; // loop iteration index loopItem?: unknown; // forEach __item, for compensation loopIndex?: number; // forEach __index, for compensation childExecutionId?: string; // on a sub: record compensation?: { status: 'compensated' | 'compensation-failed' | 'compensation-skipped'; at: number; error?: string; }; } ``` ## Events 15 types. Subscribe with `on`, `onAny`, `subscribe`, or the `onEvent` constructor option. | Group | Types | |---|---| | Lifecycle | `workflow:started`, `workflow:completed`, `workflow:failed`, `workflow:waiting`, `workflow:compensating` | | Steps | `step:started`, `step:completed`, `step:failed`, `step:retry` | | Signals | `signal:received`, `signal:timeout` | | Rollback | `compensation:started`, `compensation:completed`, `compensation:failed`, `compensation:skipped` | Every event carries `type`, `executionId`, `workflowName`, `timestamp`. Step and compensation events add `stepName`, and `result` / `error` / `attempt` / `maxAttempts` where they apply. These are live in-process notifications, not a persisted event log. A subscriber attached after an event was emitted does not receive a replay; use `getExecution()` for durable truth. A listener that throws cannot break engine delivery because dispatch isolates each callback. ## Step context ```typescript { input: TInput; steps: TSteps; signals: Record; executionId: string; signal?: AbortSignal; // aborted when this attempt times out idempotencyKey?: string; // this execution of this step forwardIdempotencyKey?: string; // compensate handlers only } ``` `forEach` and loop bodies additionally see `ctx.steps.__item` and `ctx.steps.__index`. ## How it works `engine.start()` writes an execution row and enqueues the first top-level node as an ordinary bunqueue job on an internal queue. A worker picks it up, persists the records produced inside that node, and enqueues its successor. Inline branch, parallel and loop steps are not separate queue jobs, but each has its own durable step record. Signals store their payload and re-enqueue a parked node. A failure walks eligible records in reverse start order and calls their `compensate` handlers. Queue delivery supplies persistence and worker concurrency. Workflow execution state, decision journaling and the event stream come from the workflow store and emitter, so they remain distinct from queue-job state. ## Limitations | Limitation | Details | |---|---| | **One engine per process** | No distributed coordination. A second engine with a different explicit `dataPath` is rejected; engines sharing a path still do not coordinate independently. [Why](/guide/workflow/durability/#one-engine-per-process). | | **At-least-once** | Recovered steps may re-run. Make external effects idempotent. | | **No `indeterminate` state** | A failed step is treated as possibly-committed and is compensated. There is no way yet to declare "this failed before any effect", so a clean failure is compensated too. | | **At-least-once interrupted work** | Completed records inside branches, parallel groups, loops and maps are skipped; a record left running has an unknown outcome and can replay. | | **Compensations get no retry** | A handler runs once, bounded by the step's own `timeout`. A transient failure parks the run instead of being retried. | | **No isolation between sagas** | Sagas are ACD, not ACID: a concurrent saga can read state another will later compensate. | | **Recovery is manual** | `engine.recover()` must be called on startup. | | **`close()` does not exit** | Pair it with `shutdownManager()`. | | **Sub-workflows are polled** | Timeout and poll interval are configurable, but the parent holds a worker slot while waiting. Timeout does not forcibly cancel a live child. | | **Offset pages are not snapshots** | Ordering is total (`createdAt`, then ID), but inserts between pages can shift offsets. | When these matter, reach for [Temporal](https://temporal.io) for multi-region HA, or [Inngest](https://www.inngest.com) for serverless-first operation. For parent/child job dependencies without rollback, bunqueue's own [Flow Producer](/guide/flow/) is lighter than a workflow. --- # Human Approval Gates (Signals) Pause a workflow until a person decides. Durable approval gates that survive restarts, with timeouts, multi-stage sign-off and rollback on rejection. URL: https://bunqueue.dev/guide/workflow/approval/
guide · workflow engine

Stop, and wait for a person.

A parked run releases its worker slot and keeps its durable state in SQLite. It can sit there for a day, across a redeploy, and still pick up exactly where it left off.

`waitFor` pauses a run until something outside it says to continue. The run stops occupying a worker and its state is on disk. While the process is alive, a timed gate also has one lightweight in-memory timer; the execution itself is not held in a worker closure. ```typescript const flow = new Workflow<{ amount: number }>('expense-approval') .step('submit', async (ctx) => { await slack.notify('#approvals', `Expense of ${ctx.input.amount} needs review`); return { submitted: true }; }) .waitFor('manager-approval', { timeout: 86_400_000 }) // optional: fail after 24h .step('process', async (ctx) => { const decision = ctx.signals['manager-approval'] as { approved: boolean }; return { status: decision.approved ? 'paid' : 'rejected' }; }); const run = await engine.start('expense-approval', { amount: 120 }); // state is 'waiting'. Minutes, hours or days later, from anywhere: await engine.signal(run.id, 'manager-approval', { approved: true }); ``` The payload lands in `ctx.signals['manager-approval']` for every step after the gate. ## It survives a restart An approval already accepted before a crash is stored in the execution row and is not lost. Because `signal()` is an in-process API, nobody can call it while the only engine process is down. After restart, create the engine, register the same definition, call `recover()`, and then accept new approvals normally. `recover()` also reconstructs timed gates because their timer handles are process-local. The remaining time is computed from when the wait actually started, so a restart does not hand a 24-hour gate a fresh full window. Without recovery, an untouched timed run remains parked because no timer exists to re-enqueue it. ## Rejection should unwind A rejection is an abort, not a completion. Throw on it, and everything the run did before the gate is rolled back: ```typescript .step('propose', async (ctx) => askForDeletionPlan(ctx.input), { compensate: async () => discardPlan(), }) .waitFor('human-approval', { timeout: 86_400_000 }) .step('execute', async (ctx) => { const decision = ctx.signals['human-approval'] as { approved: boolean }; if (!decision.approved) throw new Error('operator rejected the action'); return performDeletion(ctx.steps.propose); }, { retry: 1 }) ``` Note `retry: 1`. A deliberate throw should be believed the first time, not retried five times with backoff. ## Multi-stage sign-off Chain gates. Each one parks the run again: ```typescript .step('submit', submit) .waitFor('manager-approval') .step('prepare-payment', prepare) .waitFor('finance-approval') .step('pay', pay) ``` Use distinct event names. A signal for an event the run is not waiting on is recorded and consumed later if the run ever reaches that gate, so ordering mistakes are silent rather than loud. ## Timeouts ```typescript .waitFor('manager-approval', { timeout: 86_400_000 }) ``` If nothing arrives in time, a `signal:timeout` event fires, the run fails, and the rollback begins. Without a timeout the run waits indefinitely, which is a legitimate choice for a gate that genuinely has no deadline. Timer handles cannot represent more than about 24.8 days in one call. The engine therefore arms long waits in chunks while preserving the original deadline, so a 90-day gate does not wrap around and fire immediately. ## Checking on a parked run ```typescript const exec = engine.getExecution(run.id); exec?.state; // 'waiting' exec?.steps['__waitFor:manager-approval']; // when the wait started // Everything parked, across every workflow: engine.listExecutions(undefined, 'waiting'); ``` `listExecutions` returns 100 rows by default and supports deterministic pages: ```typescript engine.listExecutions(undefined, 'waiting', { limit: 100, offset: 100 }); ``` Pages order by creation time and then execution ID. Inserts between offset pages can shift later pages, so this is not a snapshot/cursor API. `waitFor` has no `timeout: 0` idiom. Omit the option to wait indefinitely: passing `0` is a deadline that has already passed, so the gate fails at once and the run rolls back work that was already done. Watch for `timeout: config.approvalMs ?? 0`, which reads like a safe default and is the one value that breaks every gate. On a step `timeout: 0` does mean unbounded, so the same literal reads opposite ways in the two places. ## A signal only reaches a live run `signal()` throws if the run is not `running` or `waiting`. A finished run used to accept one: the payload was written into the persisted row and `signal:received` was emitted, so a dashboard reported an approval against a run that had already ended, a closed audit record was quietly amended, and the caller got a clean return for a delivery that could not have had any effect. An approval racing a run to its end is real, so handle the rejection rather than assume it cannot happen: ```typescript try { await engine.signal(run.id, 'manager-approval', { approved: true, by: user.id }); } catch (err) { // The run finished, failed or timed out before the decision arrived. // Whatever it decided is now history: reconcile, do not retry. } ``` The payload is optional, and `signal(id, 'manager-approval')` on its own counts as a delivery: the gate opens on the event having arrived, not on it carrying anything. Delivery is first-writer-wins per event. A duplicate signal is rejected and cannot overwrite the accepted approval payload. An event name that cannot be stored is refused too, at `register()` and again at `signal()`: an empty name, and `__proto__`, which assignment would write to an object's prototype rather than store as a key. ## One event per gate A delivered signal is never consumed: `ctx.signals` keeps it for the rest of the run. Two gates waiting on the same event would therefore both be opened by a single `signal()`, so a workflow that declares the same event twice is rejected at `register()`. ```typescript .waitFor('manager-approval') // not 'approve' .step('release-funds', releaseFunds) .waitFor('finance-approval') // not 'approve' again ``` Give each gate its own name. That is also what you want in the audit trail: `signals` then records who approved what, rather than one entry standing for two decisions. --- # Durability: Idempotency Keys & Crash Recovery What survives a crash in the bunqueue workflow engine, what replays, and how stable idempotency keys keep a retry from charging a card twice. URL: https://bunqueue.dev/guide/workflow/durability/
guide · workflow engine

Survive the restart.

What is written down, what is replayed, and why a stable idempotency key is the difference between a retry and a second charge.

## Idempotency keys Every step is given a key that is **stable across retries and across crash-resume**, and different for a different run. Pass it to a provider and a repeat lands on the same operation instead of creating a new one: ```typescript .step('charge', async (ctx) => { return stripe.charges.create( { customer: ctx.steps.validate.customerId, amount: 4900 }, { idempotencyKey: ctx.idempotencyKey }, // identical on every attempt ); }, { compensate: async (ctx) => { // The forward step may have committed without persisting its output. // Reconcile by key rather than depending on a result that may not exist. const charge = ctx.steps.charge ?? await stripe.charges.retrieveByIdempotencyKey(ctx.forwardIdempotencyKey); if (charge) { await stripe.refunds.create({ charge: charge.id }, { idempotencyKey: ctx.idempotencyKey }); } }, }) ``` The shape is `run:step#occurrence:direction`, for example `wf_7ad3…c910:charge#0:forward`. Run IDs are opaque `wf_` plus 128 bits from the runtime cryptographic entropy source. | Part | Why | |---|---| | `run` | A different run is a different logical operation | | `step` | Each step is its own operation | | `occurrence` | Loop iterations share a step name; the index separates them, and comes from the iteration so a replay presents the key it used the first time | | `direction` | `forward` or `compensate`, because a refund is not the charge | :::danger[Never derive a key from the attempt number] It is the most common way to get this wrong: every retry then asks the provider for a brand-new charge instead of being deduplicated into the first one. The key must be **invariant** across attempts, which is exactly what the engine gives you. ::: The compensate handler additionally receives `ctx.forwardIdempotencyKey`, the key the forward execution used, so a rollback can ask the provider *"did this actually happen?"* when the outcome is in doubt. ## Crash recovery Call `recover()` on startup, after registering every workflow and before starting new ones: ```typescript const engine = new Engine({ embedded: true, dataPath: './data/wf.db' }); engine.register(orderFlow); engine.register(paymentFlow); const recovered = await engine.recover(); console.log(`Recovered ${recovered.total} executions`); // { running, waiting, compensating, total } ``` | State found | What recovery does | |---|---| | `running` | Re-enqueues the current node so the run continues | | `waiting` | Re-arms the signal timeout for its **remaining** time; if the signal was already recorded before recovery, resumes immediately | | `compensating` | Re-enters the unwind. A reversal recorded as succeeded is skipped, and one recorded `compensation-failed` still blocks the chain, so the run parks again rather than reporting a clean rollback | A timeout is re-armed on the time that is left, not from zero. A 24-hour approval window on a process restarted after 23 hours fires in one hour. If `close(true)` is followed immediately by a replacement Engine in the same process, a JavaScript compensate handler from the old Engine may still be settling after its store closed. Recovery waits for that exact local claim, reloads the durable row through the replacement store, and retries only if the unwind is still owed. The claim prevents overlapping local reversals; it is not a distributed lock, so provider-side idempotency is still required across process crashes or multiple processes. A forced close aborts the old Engine's execution generation before Worker teardown. Every asynchronous continuation — retry backoff, map or loop work, sub-workflow polling, signal waits and recovery — checks that generation before it can persist, publish, emit, or start more user code. A replacement Engine continues from the last SQLite checkpoint; the old parent cannot advance merely because a child completed after shutdown. This does not cancel a handler that had already started, so its external effect can still be observed before recovery replays the unknown outcome and the idempotency requirement remains. An already-started compensation retains its local claim long enough to checkpoint and release it, while no following reversal starts. Plain `close()` is graceful and does not abort the generation: it waits for active control flow to finish and checkpoint normally. :::caution[Recovery is a manual call] Nothing resumes on its own. If you never call `recover()`, an interrupted run sits in the database untouched. ::: ## What resumes, and what replays This is the honest picture, and it is the main difference from a replay-based engine like Temporal. | | Behaviour after a crash | |---|---| | A chain of `.step()` nodes | **Resumes** at the node it reached. Completed steps are not re-run. | | Loop iterations (`doUntil`, `doWhile`, `forEach`) | **Resume** at the interrupted iteration. Earlier ones are memoised and skipped. | | The interrupted step or iteration itself | **Replays**, because it never finished and cannot be assumed done | | A `branch` path or `parallel` group | **Resumes unfinished records.** The chosen branch is journaled and completed inner steps are skipped; work left running can replay. | | Compensations | **Resume.** A reversal with a persisted terminal outcome is skipped; one interrupted before that write can replay. | | `waitFor` gates | **Survive.** A signal persisted before a crash is still there, and the remaining timeout is reconstructed. | | Branch/loop/item decisions | **Replay from the journal.** Conditions and extractors are not re-evaluated after their choice was persisted. | So an agent killed at turn 7 of a loop resumes at turn 7. A parallel group interrupted halfway re-enters the group, but completed siblings short-circuit and only records with an unknown outcome can dispatch again. ## At-least-once, and what to do about it If a step partially committed to an external system before the crash, the engine cannot know. The step re-runs. That is the guarantee, stated plainly: > Externally visible steps must be idempotent. `ctx.idempotencyKey` is stable across resume precisely so a provider can absorb the repeat. Where the provider has no such mechanism, make the operation naturally idempotent: write with a deterministic id, upsert instead of insert, check before creating. ## Persistence ```typescript new Engine({ embedded: true, dataPath: './data/wf.db' }) ``` Without `dataPath` the execution store is **in-memory**: runs vanish on restart and `recover()` finds nothing. That is fine for a test and wrong for anything else. Execution rows accumulate, so trim them: ```typescript engine.cleanup(7 * 24 * 60 * 60 * 1000); // delete after 7 days engine.cleanup(7 * 24 * 60 * 60 * 1000, ['completed']); // only completed ones engine.archive(30 * 24 * 60 * 60 * 1000); // move to an archive table engine.getArchivedCount(); ``` `cleanup` deletes; `archive` moves rows to `workflow_executions_archive` transactionally, up to 1000 per call. Both take a minimum age and the cutoff is inclusive, so `cleanup(0)` and `archive(0)` really do take everything terminal, including a run that finished in the current millisecond. ## Nested runs belong to their parent A `subWorkflow` child is a row like any other, but its lifecycle is owned by the parent that started it, so it is deliberately not offered to `recover()` on its own. Driving it independently would re-run its steps behind the parent's back, and the fresh records would carry no rollback outcome, so the parent's later unwind would reverse the same child a second time. The parent claims its child as soon as it starts one, before waiting on it, and a later entry into that node resumes the existing child instead of starting another. That matters most after a restart, when `recover()` re-enqueues the parent's current node: without the claim the child ran twice, so a child that provisions a resource could provision it twice. The child poll deadline is measured from the child's original creation time, so restarting the parent does not grant a fresh timeout window. Configure it at the node: ```typescript .subWorkflow('payment', (ctx) => ctx.input, { timeout: 15 * 60_000, pollInterval: 250, }) ``` Expiry fails the parent but does not forcibly cancel a child that is still running. The parent can therefore park in `compensation-stuck` until the child settles and an operator resumes or abandons the unwind. If a parent row is removed unexpectedly, its non-terminal child becomes an orphan. Recovery includes that child again instead of filtering it forever, so the execution is observable and can make progress rather than remaining stranded. ## Definition identity Registration seals the workflow graph. Every execution persists a SHA-256 identity covering node shape and scheduling options, plus the workflow's explicit semantic revision: ```typescript const flow = new Workflow('orders', { revision: 3 }); ``` Bump `revision` when handler or condition behavior changes without changing the graph shape. A live persisted execution cannot be driven by a different definition; the engine fails closed rather than silently changing its schedule mid-run. ## One engine per process The engine has no distributed coordination, and the limit is tighter than it first looks: it is one engine per **process**, not one per database. Two engines in the same process cannot own different databases. `getSharedManager()` memoises the first `QueueManager` it builds and now rejects a later explicit `dataPath` that identifies another database. This fail-fast check prevents two workflow stores from silently sharing one internal `__wf:steps` queue. ```typescript const a = new Engine({ embedded: true, dataPath: './a.db' }); const b = new Engine({ embedded: true, dataPath: './b.db' }); // throws dataPath conflict ``` `Engine.close()` does not shut down the process-wide queue manager. Register every workflow on one engine, or close all embedded clients and call `shutdownManager()` before switching paths. The same rule covers the distributed case: two engines in different processes pointed at one database would both recover the same executions and both drive them. --- # Your First Workflow in Bun Build a three-step order pipeline with automatic rollback, run it, and inspect what the engine recorded. Copy-paste ready, verified in CI. URL: https://bunqueue.dev/guide/workflow/quickstart/
guide · workflow engine

Your first workflow, in one file.

Three steps, one rollback handler, no services to start. By the end you will have run it, broken it on purpose, and read back exactly what the engine recorded.

Ten minutes, one file, no services to start. ```bash bun add bunqueue ``` ## The workflow Each `.step()` gets a name and a handler. The handler's return value becomes `ctx.steps.` for every step after it, fully typed, no casts: ```typescript import { Workflow, Engine } from 'bunqueue/workflow'; const orderFlow = new Workflow<{ orderId: string; amount: number }>('order-pipeline') .step('validate', async (ctx) => { // ctx.input is typed as { orderId: string; amount: number } if (ctx.input.amount <= 0) throw new Error('Invalid amount'); return { orderId: ctx.input.orderId, validated: true }; }, { retry: 1 }) .step('charge', async (ctx) => { // ctx.steps.validate is typed from the previous step's return value const txId = await payments.charge( ctx.steps.validate.orderId, ctx.input.amount, { idempotencyKey: ctx.idempotencyKey }, ); return { transactionId: txId }; }, { compensate: async (ctx) => { // A failed charge may have committed without returning its transaction id. const charge = ctx.steps.charge ?? await payments.findByIdempotencyKey(ctx.forwardIdempotencyKey); if (charge) { await payments.refund(charge.transactionId, { idempotencyKey: ctx.idempotencyKey, }); } }, }) .step('confirm', async (ctx) => { await mailer.send( 'order-confirm', { txId: ctx.steps.charge.transactionId }, { idempotencyKey: ctx.idempotencyKey }, ); return { emailSent: true }; }); ``` The provider methods are application code, but their idempotency arguments are not decorative. They make a retry of an outcome-unknown charge or email land on the same external operation. The compensate handler also reconciles by the forward key because the charge most in need of reversal may be the one whose response never came back. ## Run it ```typescript const engine = new Engine({ embedded: true, dataPath: './data/wf.db' }); engine.register(orderFlow); await engine.recover(); // after every definition is registered, before new work const run = await engine.start('order-pipeline', { orderId: 'ORD-1', amount: 99.99 }); ``` :::caution[`dataPath` is what makes it durable] Without it the execution store is in-memory and a restart loses every run in flight. Pass a path in anything but a throwaway script. ::: ## Watch it finish `start()` returns as soon as the first node is enqueued; the run continues in the background. Poll durable state when you need a definitive answer: ```typescript const terminal = new Set(['completed', 'failed']); let exec = engine.getExecution(run.id); while (exec && !terminal.has(exec.state)) { await Bun.sleep(50); exec = engine.getExecution(run.id); } console.log(exec?.state); ``` Event subscriptions are live notifications, not a replay log. Attach `engine.onAny()` before `start()` if you must observe the complete event sequence; `subscribe(run.id, ...)` is useful for updates after the handle is known, but a very short workflow may already have emitted early events. ## Make it fail Change `confirm` to throw and run it again. The engine records the failure, then walks backwards through the steps that completed and calls their `compensate` handlers in reverse: ```typescript const failedExecution = engine.getExecution(run.id); failedExecution?.state; // 'failed' failedExecution?.failureReason; // the error from `confirm` failedExecution?.rollbackStatus; // 'completed', unwind finished failedExecution?.steps.charge?.compensation?.status; // 'compensated' ``` Two separate facts, two separate fields: **why the run failed**, and **what the rollback then did**. They are not the same question, and collapsing them makes it impossible to alert on the right one. ## Shutting down cleanly ```typescript import { shutdownManager } from 'bunqueue/client'; await engine.close(); shutdownManager(); // stops process-wide timers and flushes pending writes ``` Without `shutdownManager()` a script that finishes its work will not exit: bunqueue's background maintenance timers are shared across the process and keep the event loop alive. ## Next - [Steps & Control Flow](/guide/workflow/steps/), retries, branching, parallel, loops - [Rollback](/guide/workflow/rollback/), what the undo actually guarantees --- # Rollback: Saga Compensation in TypeScript How the workflow engine undoes a failed multi-step process: unwind order, per-step outcomes, a rollback that itself fails, and the point of no return. URL: https://bunqueue.dev/guide/workflow/rollback/
guide · workflow engine

Undo, in the right order.

A step that changed the outside world declares its inverse. When a later step fails, the engine runs those inverses in reverse, records every outcome, and tells you when one of them did not work.

A step that changed the outside world declares its inverse. When a later step fails, the engine runs those inverses in reverse. ```typescript const flow = new Workflow('money-transfer') .step('debit-source', async () => { await accounts.debit(from, amount); return { debited: true }; }, { compensate: async () => { await accounts.credit(from, amount); }, }) .step('credit-target', async () => { await accounts.credit(to, amount); return { credited: true }; }, { compensate: async () => { await accounts.debit(to, amount); }, }) .step('send-receipt', async () => { throw new Error('Email service down'); }); ``` Observed order: `debit(from)` → `credit(to)` → **fail** → `debit(to)` → `credit(from)`. The books balance. ## Unwind order is reverse *start* order Not reverse completion order. With parallel steps completion order depends on timing and is not reproducible; start order is fixed by your builder, so the unwind is deterministic across runs. It matters as soon as you have a `parallel()` block: ``` started: database, bucket, index completed: bucket, index, database ← timing unwind: index, bucket, database ← reverse START order ``` :::caution[Reverse start order is a heuristic, not a dependency graph] Builder order is deterministic, but it does not describe dependencies between work that was declared concurrent. If one parallel step must remain alive while another is undone, reverse builder order may be the wrong order for that pair. Compensations of concurrent steps must therefore be **mutually independent**. If they are not, split them into sequential steps so their dependency and rollback order are explicit. ::: ## Every eligible step gets exactly one outcome Success is recorded as loudly as failure, so "did the refund actually run?" is answerable from the record alone: ```typescript exec.steps['charge'].compensation; // { status: 'compensated' | 'compensation-failed' | 'compensation-skipped', at, error? } ``` The same outcomes are emitted as `compensation:started`, `compensation:completed`, `compensation:failed` and `compensation:skipped` events. ### The failed step is rolled back too Not only the completed ones. A charge that reached the provider and then lost its response is recorded as *failed* while the money has already moved, and it is the step most likely to need undoing. The consequence: a compensate handler must tolerate the absence of its own step's output. Use the [idempotency key](/guide/workflow/durability/#idempotency-keys) to reconcile instead. ## When a compensation fails The unwind **stops** and the run parks in `compensation-stuck`. It does not plough on, because continuing would undo work whose dependencies are still standing. It does not end silently either: ```typescript const exec = engine.getExecution(run.id); exec?.state; // 'compensation-stuck' exec?.rollbackStatus; // 'stuck' exec?.failureReason; // why the RUN failed, a separate axis exec?.steps.charge?.compensation; // compensation-failed exec?.steps.reserve?.compensation; // undefined, not reached ``` The steps behind the failure are deliberately left **without** an outcome, so a resume can still reach them. Two ways out: ```typescript // The dispute cleared, the endpoint is back: retry it and finish the unwind. await engine.resumeCompensation(run.id); // Or accept a partial rollback: the rest are recorded as skipped, the run ends. await engine.abandonCompensation(run.id); ``` After `abandonCompensation` every eligible step carries an outcome, and that is where "exactly one, never zero" is finally paid. ### A hung compensation counts as a failed one A `compensate` handler is bounded by the step's own `timeout`, the same one that bounds the forward handler, defaulting to 30000 ms. This matters more than it sounds: rollbacks run precisely when a provider is having a bad day, which is when a call hangs rather than refusing. `timeout: 0` means "no bound" for the forward handler, and that is your call to make: a step may legitimately run for hours. A reversal is a different case, so it falls back to 30000 ms rather than running unbounded. A reversal that never settles would hold the engine's in-flight claim on that run, locking it out of `recover()`, `resumeCompensation()` and `abandonCompensation()` for the life of the process, and leaving it `compensating` instead of parked, with no operator exit at all. ```typescript .step('charge', chargeCard, { timeout: 1000, // bounds chargeCard AND refund compensate: refund, }) ``` If `refund` never settles, the unwind does not wait for it forever. The step is recorded like any other failed reversal and the run parks: ```typescript exec?.state; // 'compensation-stuck' exec?.rollbackStatus; // 'stuck' exec?.steps.charge?.compensation; // compensation-failed, timed out after 1000ms ``` Without that bound the run would sit in `compensating` instead, which is worse than a parked one: it is not `compensation-stuck`, so there is nothing to `resumeCompensation` or `abandonCompensation`. A concurrent local `engine.recover()` waits for the exact claim owner and reloads the durable row before deciding whether it must retry, but it cannot make a permanently hung handler finish. The timeout is what guarantees that ownership settles and the run reaches an operator-visible state. ### A failure that was never resolved keeps the chain stopped An unwind interrupted by a crash leaves the run `compensating`, and `recover()` drives it again at the next startup. That second pass reads the outcomes the first one wrote, and a reversal recorded `compensation-failed` still blocks everything behind it: the pass stops there and the run parks again, exactly as it did the first time. A forced Engine close is a smaller version of the same race: JavaScript already inside a compensate handler may briefly outlive the Engine whose store was closed. A replacement Engine in the same process waits for that specific local owner, then reloads SQLite through its own store and resumes only if the unwind is still owed. It never assumes that losing the local claim means the durable work completed. It has to work that way. Treating a recorded failure as "already dealt with" would let the pass walk past a refund that never went through, reach the end and report `rollbackStatus: 'completed'` over money nobody returned, which is the one reading an operator must be able to trust. `compensated` and `compensation-skipped` are settled for good and are never re-run. Only `resumeCompensation()` retries a failed one, because that is what it asks for. ### Two fields, not one `failureReason` says why the run failed. `rollbackStatus` says what the engine did afterwards: | `rollbackStatus` | Meaning | |---|---| | `completed` | Every eligible step was compensated | | `not-applicable` | Committed at the pivot, or nothing was eligible | | `stuck` | A compensation failed; the rest were not attempted | "The payment failed" and "the refund never went through" need different alerts. Collapsing them into one field makes that impossible. ## The point of no return `.pivot()` marks the step after which the saga is **committed**. Past it nothing is rolled back, not the steps after it and not the ones before it either: ```typescript const flow = new Workflow('provision') .step('reserve-subdomain', reserve, { compensate: release }) .step('charge-setup-fee', charge, { compensate: refund }) .pivot() // committed from here .step('send-welcome-email', sendWelcome) // irreversible .step('activate-tenant', activate); // retry forward, never unwind ``` Releasing the subdomain of a tenant who has already been sent a welcome email is exactly what the pivot exists to prevent. A run that fails after it reports `rollbackStatus: 'not-applicable'` and keeps everything it built. If a workflow declares no pivot, everything stays compensatable to the end. ## Nested workflows `.subWorkflow(name, inputMapper)` runs another registered workflow as a step. Its results land under `ctx.steps['sub:']`: ```typescript const paymentFlow = new Workflow<{ amount: number }>('payment') .step('authorize', async (ctx) => authorizePayment(ctx.input.amount), { compensate: async (ctx) => voidPayment(ctx.forwardIdempotencyKey), }); const orderFlow = new Workflow('order') .step('create-order', async () => ({ orderId: 'ORD-1', total: 99 }), { compensate: async () => cancelOrder(), }) .subWorkflow('payment', (ctx) => ({ amount: (ctx.steps['create-order'] as { total: number }).total, })) .step('confirm', async () => { throw new Error('confirmation failed'); }); engine.register(paymentFlow); // register the child too engine.register(orderFlow); ``` **Rolling back a sub-workflow runs the child's own unwind.** A child that succeeded before its parent failed is not left standing: it compensates through its own handlers, in its own reverse start order, before the parent continues with its own. In the example above the payment is voided first, then the order is cancelled. This applies whether the child finished or not. A child that FAILED is rolled back through its own unwind too, and the parent's `sub:` record is settled `failed` rather than left in flight, so a dashboard never shows a child still running under a parent that has already stopped. If the child parks in `compensation-stuck`, the parent inherits it and parks too, rather than reporting a clean rollback over a half-undone child. The parent's `rollbackStatus` reads `stuck` and its `failureReason` names the child and the two ways out, `resumeCompensation` and `abandonCompensation`. Resuming the parent reaches the child: the retry is forwarded, so the child's failed reversal is attempted again and the whole saga can finish from one call. That forwarding applies only while the child is still parked. If you explicitly call `abandonCompensation(childId)`, the child becomes terminal (`failed` with `rollbackStatus: 'stuck'`). Resuming an ancestor cannot override that operator decision or run the child's compensators again: the ancestor remains `compensation-stuck` until you abandon it separately or otherwise reconcile the partial rollback. ## Loops Every iteration of a `doUntil`, `doWhile` or `forEach` is compensated separately, in reverse order, and each one's handler sees its OWN result rather than the last iteration's. The iteration that FAILED is compensated as well, and it is the one most likely to need it: a charge that reached the provider and then lost the response is recorded failed while the money has already moved. It has no result to hand its handler, for the same reason, so write the handler to reverse by idempotency key rather than by a transaction id it may never have received: ```typescript .step('charge', async (ctx) => { return await provider.charge({ key: ctx.idempotencyKey, amount: 10 }); }, { compensate: async (ctx) => { // The failed turn has no result to read. Reverse by the key the forward call was // made with, which the engine hands every compensate handler. await provider.refundByKey(ctx.forwardIdempotencyKey); }, }) ``` Each iteration gets its own `idempotencyKey`, so the keys do not collide between turns, and `ctx.forwardIdempotencyKey` in the handler is the key that iteration charged with. A handler that dereferences a result the failed turn never produced throws, and a compensation that throws halts the unwind at that step, leaving everything behind it untouched. :::caution[Sub-workflows hold a worker slot] The parent polls while the child runs. The default ceiling is 300 seconds and the default interval is 100 ms; configure both with `.subWorkflow(name, mapper, { timeout, pollInterval })`. Keep `concurrency` above the number of concurrently nested runs, or the children have no slot left to run in. The timeout deadline is based on the child's original creation time, so restarting the parent does not reset it. If a child outlives that ceiling, the parent's step fails on the timeout while the child is still running. A child that has not stopped is never rolled back: rolling it back would run its reversals underneath its own forward steps, and it could then finish `completed` with its undo already done. The parent parks in `compensation-stuck` instead. The two fields say different things, and the specific one is not the one you might reach for first: | Field | What it holds here | |---|---| | `failureReason` | why the parent's step failed, `Sub-workflow "" () timed out` | | `steps['sub:'].compensation.error` | why the rollback stopped, the child is still running and cannot be rolled back until it stops | Once the child stops on its own, whether it completes or fails, `resumeCompensation()` on the parent picks the unwind up and reaches it. There is no API to stop a running execution, so for a child that is genuinely wedged the exit is `abandonCompensation()` on the parent, which records the remaining reversals as skipped and makes the parent terminal. ::: ## Writing good compensations | | | |---|---| | Make them idempotent | An interrupted unwind resumes, and at-least-once applies here too | | Reconcile, don't assume | The forward step may have committed without persisting its output | | No user-visible side effects beyond the technical undo | Do not email "your order was cancelled" for an order that never existed | | Keep them leaves | If undoing something needs its own saga, the forward step was too big, so split it | | Only where it's worth it | A step that computed a value in memory does not need one | --- # Steps & Control Flow Step context, retries with exponential backoff, timeouts, schema validation, branching, parallel steps and loops in the bunqueue workflow engine. URL: https://bunqueue.dev/guide/workflow/steps/
guide · workflow engine

The shapes a process can take.

Sequences, forks, fan-out and loops. The engine journals their decisions and outcomes so completed work can be skipped and compensatable effects can be walked back.

## Step context Every handler receives one object: | Property | Type | What it is | |---|---|---| | `ctx.input` | `TInput` | What you passed to `engine.start()` | | `ctx.steps` | `TSteps` | Results of completed steps, keyed by step name | | `ctx.signals` | `Record` | Payloads from `engine.signal()` | | `ctx.executionId` | `string` | This run's id | | `ctx.signal` | `AbortSignal \| undefined` | Ordinary step attempt: aborted when its timeout expires | | `ctx.idempotencyKey` | `string \| undefined` | Ordinary step/compensation attempt: stable effect identity, see [Durability](/guide/workflow/durability/) | | `ctx.forwardIdempotencyKey` | `string \| undefined` | Compensation only: identity used by the forward attempt | Use `ctx.steps['step-name']` for hyphenated names. Typing is automatic: `Workflow` accumulates each step's return type, so later steps see earlier results without casts. Branch/loop conditions, item extractors, input mappers and map transforms receive the durable data fields but are not provider-effect attempts, so they do not receive attempt-only keys or a cancellation signal. ## Retries and timeouts ```typescript .step('call-api', async (ctx) => { const res = await fetch('https://api.external.com/data', { signal: ctx.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); }, { retry: 5, // max attempts (default: 3) timeout: 10000, // per-attempt timeout in ms (default: 30000, 0 = disabled) }) ``` Backoff is `min(500ms × 2^(attempt-1), 30s)` plus up to 50% jitter. When attempts run out the step fails and the rollback begins. Set `retry: 1` on steps that throw deliberately, such as validation or guard clauses, so a rejection is not retried five times before being believed. The persisted attempt count is cumulative. If a process stops after attempt two, recovery starts at attempt three rather than granting a new retry budget. :::caution[Cancellation is cooperative] When a step times out the engine aborts `ctx.signal` and stops waiting. Pass that signal to `fetch` and other cancellable I/O. A handler that ignores it can keep running underneath while a retry starts, so external effects still need idempotency. ::: ## Schema validation Any object with a `.parse()` method works: Zod, ArkType, Valibot. There is no runtime dependency on a schema library: ```typescript import { z } from 'zod'; .step('charge', async (ctx) => ({ transactionId: 'tx_123', charged: 99.99 }), { inputSchema: z.object({ orderId: z.string(), amount: z.number().positive() }), outputSchema: z.object({ transactionId: z.string(), charged: z.number() }), }) ``` `parse()` output is used, so coercion is real: `.default()` fills a missing field, `.transform()` rewrites, `z.coerce.date()` hands your handler a `Date` and not the string it arrived as. A validator that only asserts and returns nothing is fine too, the original value is kept. The two differ in reach. `outputSchema` coercion is what the run carries forward: it lands in the step's record, and later steps and the compensate handler all read the coerced value. `inputSchema` coercion is scoped to the step that declares it, so it shapes that handler's `ctx.input` and nothing else. A later step without its own schema still sees the original run input. `inputSchema` validates `ctx.input` before the handler runs; `outputSchema` validates the return value. Input parsing is done once per retry episode and its coerced value (or validation error) is reused across those attempts; recovery starts a new episode and parses again. A validation failure is a step failure, so it consumes the declared retry attempts and then triggers rollback. The reason lands in `exec.failureReason` as `Output validation failed for "charge": ...`. ## Branching The branch function returns a string; the matching `.path()` runs, the others do not. Steps after the branch block always run: ```typescript const flow = new Workflow('support-ticket') .step('classify', async (ctx) => { const priority = await scoreTicket(ctx.input); return { priority }; // 'high' | 'low' }) .branch((ctx) => (ctx.steps.classify as { priority: string }).priority) .path('high', (w) => w.step('assign-senior', async () => ({ assignedTo: await roster.senior() })) ) .path('low', (w) => w.step('auto-reply', async (ctx) => { await mailer.sendTemplate('auto-reply', ctx.input); return { assignedTo: 'bot' }; }) ) .step('log-ticket', async () => ({ logged: true })); // always runs ``` The selected path is journaled before any path effect runs, so recovery does not re-evaluate a non-deterministic condition. Returning an undeclared path fails explicitly, and declaring the same path name twice throws while building the workflow. :::caution[Paths hold steps, nothing else] A path runs inline inside a single job, so it has nowhere to park a `waitFor` and no dispatcher for a nested `branch`. The builder throws as soon as `.path()` closes over a non-step node rather than quietly dropping it, because an approval gate that silently does not gate is the worst way to be wrong. The same build-time rule applies to `parallel()` and loop bodies. Model anything richer as a [sub-workflow](/guide/workflow/rollback/#nested-workflows). ::: ## Parallel steps Steps inside `.parallel()` run concurrently, and all of them settle before the workflow moves on. Their results land in `ctx.steps` like any other step: ```typescript .parallel((w) => w .step('fetch-orders', async () => db.orders.findByUser(userId)) .step('fetch-preferences', async () => db.preferences.get(userId)) ) .step('merge', async (ctx) => ({ orders: ctx.steps['fetch-orders'], prefs: ctx.steps['fetch-preferences'], })) ``` If any of them fails the whole group fails with an `AggregateError` containing every failure, and the rollback begins, including for the siblings that succeeded. `failureReason` carries all of them too, as `2 failures: card declined; warehouse offline`, so a group that broke in two places does not record one cause and send you looking for a single problem that was not the only one. Waiting for the in-flight siblings before unwinding is deliberate: a step that completes *after* the rollback started would otherwise be orphaned, with nothing left to undo it. ## Loops | | | |---|---| | `.doUntil(condition, builder, opts?)` | Runs the body, then checks. Always runs at least once. | | `.doWhile(condition, builder, opts?)` | Checks first. Can skip entirely. | | `.forEach(itemsFn, name, handler, opts?)` | One iteration per item, sequentially. | | `.map(name, fn)` | A synchronous or async transform of previous results. No retry, no timeout. | ```typescript // Poll until a deploy is ready, at most 60 checks .doUntil( (ctx) => (ctx.steps.check as { ready: boolean })?.ready === true, (w) => w.step('check', async () => ({ ready: await deploy.isReady() })), { maxIterations: 60 }, ) // One notification per user in the input .forEach( (ctx) => (ctx.input as { userIds: string[] }).userIds, 'notify', async (ctx) => { const userId = ctx.steps.__item as string; // current item const index = ctx.steps.__index as number; // current index await sendNotification(userId); return { notified: userId }; }, { retry: 3 }, ) ``` ### Every iteration is kept Results are stored under indexed names such as `notify:0` and `notify:1`, while the bare name keeps resolving to the **last** iteration for downstream steps. That is what lets a loop body read its own history: ```typescript .doUntil( (_ctx, iteration) => iteration >= 5, (w) => w.step('turn', async (ctx) => { const history = []; for (let i = 0; ctx.steps[`turn:${i}`]; i++) history.push(ctx.steps[`turn:${i}`]); // ... }), ) ``` Iterations are also **memoised**: one that already completed is not run again when the node is re-entered after a crash, so a loop resumes at the iteration it was interrupted on. See [Durability](/guide/workflow/durability/). `map` has the same durable lifecycle visibility as a step: it writes `running`, then `completed` or `failed`, and emits the corresponding events. A completed map is not transformed again when its node is re-entered after a crash. Treat a map function as pure even though JavaScript cannot enforce purity. A map left `running` has an unknown outcome and may execute again; use `.step()` with an idempotency-aware handler when the operation changes an external system. ### Limits `forEach` requires its extractor to return a real array, and throws before running anything otherwise. JavaScript is generous about what has a `length`, so this was worth making explicit: a number iterated zero times and the run still reported success, and a string iterated its characters, which turned an id list that arrived as `'u1,u2'` into five "items" nobody passed. It also throws if the list exceeds `maxIterations` (default 1000). `doUntil`/`doWhile` throw when they exceed theirs (default 100), which fails the run and triggers the rollback. A step whose name collides with a loop's `name:index` namespace is rejected at `register()`. All declared bounds are validated when the builder method is called: retries and iteration counts must be positive safe integers, timeouts must be finite and non-negative, and sub-workflow polling durations must be finite and strictly positive. Invalid values fail before any execution row is created. --- # API Reference, by version Browse every class, interface and type bunqueue exports, generated from the source of each release. Pick the version you have installed. URL: https://bunqueue.dev/reference/ import apiVersions from '../../data/apiVersions.json'; export const { versions, current } = apiVersions;
api reference

Every export, per version.

Generated from the source of each release, so the page you open documents the surface that release actually shipped, not the one on main today.

## Pick your version Version pages are keyed by `major.minor`. Patch releases share a page because, under semver, a patch cannot change the public surface. ## What is in there The reference covers exactly the entry points the package declares in its `exports` map, so everything documented is something you can actually import: | Import | What it holds | |---|---| | `bunqueue` | Server entrypoint | | `bunqueue/client` | `Queue`, `Worker`, `Bunqueue`, `FlowProducer`, `QueueEvents`, `Forwarder` | | `bunqueue/workflow` | `Workflow`, `Engine`, `WorkflowEmitter` | | `bunqueue/queue` | `QueueManager`, for embedding the broker | | `bunqueue/mcp` | The MCP server | Types that appear in those signatures are included too, so a parameter type is a link rather than a dead end. ## Reference or guide? They answer different questions, and the reference is the wrong place to start. | | Use | |---|---| | *What does this method take and return?* | The reference | | *How do I build X?* | [The guide](/guide/introduction/) | | *What crosses the wire?* | [Protocol spec](/api/tcp/) | | *What does the HTTP surface expose?* | [HTTP API](/api/http/) | ## Stability bunqueue follows semver: a breaking change to anything reachable from the entry points above means a major release. Anything reached by deep-importing a path not in the `exports` map is internal, may change in any release, and is not covered by the reference. The workflow engine is a Bun, in-process API. It is not part of the wire protocol and is not implemented in the Python, PHP, Go, Rust or Elixir clients, so it does not appear in their references. See [the workflow guide](/guide/workflow/) for what that means in practice. --- # Simulator: Try bunqueue in Your Browser Try bunqueue live in your browser: jobs flowing through priorities, retries with backoff, dead letter queues, sharding and worker concurrency. URL: https://bunqueue.dev/simulator/ import Simulator from '../../components/simulator/Simulator.tsx';
simulator

The engine, in your browser.

A faithful in-browser model of bunqueue's memory/SQLite engine. Push jobs and watch its lifecycle, waiting to active to completed, with failures bouncing through delayed backoff until they land in the dead-letter lane.

Use the **scenario chips** for one-click demos, or drive everything by hand from the control panel. The transport bar pauses the simulation clock and runs it up to 4× speed. It mirrors the FNV-1a shard mapping, priority ordering, and controls of the memory/SQLite engine; PostgreSQL uses database ordering and locks instead of in-memory delivery shards. ## What you're looking at - **Pipeline**: every job as a chip in its state lane. Active chips show live progress; retry chips carry their attempt count (`↻ 2/3`) and backoff countdown; dead letters show the failure reason. - **Shards**: bunqueue maps each queue to a shard with `fnv1aHash(queueName) & SHARD_MASK`. A cell flashes when a push lands on it. All jobs of one queue always share one shard. - **Throughput**: completions per second over the last 60 seconds of simulation time. - **Workers**: each card is one worker; the dots are its concurrency slots (filled = busy). Stopping a worker lets in-flight jobs finish first. - **Events**: the server log tail: pushes, completions, retries, dead letters, and every control action. ## Scenarios to try 1. **Burst of 80**: bulk-push 80 jobs and watch four worker slots chew through the backlog. 2. **Priorities win**: P9 jobs pushed *after* a P1 backlog still jump the whole line. 3. **Scheduled jobs**: delayed jobs wait in their lane until the countdown expires. 4. **Failure storm**: at 55% failure rate, retries back off exponentially (1.2s → 2.4s → DLQ). Hit **Retry DLQ** to give dead letters a fresh attempt budget. 5. **Rate limited**: eight worker slots, but the queue releases only 4 jobs/sec: backpressure you can see. Then compose your own: pause a queue mid-burst, drain it, or crank the failure rate while a scenario runs, effects stack like they would on a real server. ## How it maps to real bunqueue | Mechanic | Simulator | Real bunqueue | |----------|-----------|---------------| | Shard mapping | `fnv1aHash(queue) & 7`, 8 shards | Memory/SQLite: same hash with an auto-detected shard count; PostgreSQL: database row ordering/locks | | Priority order | Higher first, FIFO within a priority (4-ary min-heap) | Same | | Retries | Exponential backoff, DLQ after 3 attempts | Configurable `attempts`/`backoff` per job | | Rate limit | Token bucket, jobs/sec per queue | [`RateLimit`](/guide/rate-limiting/) command | | Storage | In-memory, resets on reload | Memory/SQLite, or authoritative PostgreSQL for multi-broker servers | | Workers | Simulated processing time (0.45–1.5s; the priority demo uses a slower 1.1–2s worker) | Your code, over TCP or [embedded](/guide/quickstart/) | Ready for the real thing? Start with the [quick start](/guide/quickstart/), then meet the [Queue](/guide/queue/) and [Worker](/guide/worker/) APIs.