- Docs
- Resources
- Changelog
Every release, documented.
All notable changes to bunqueue: features, fixes, performance work and breaking changes, newest first.
[Unreleased]
Section titled “[Unreleased]”Performance
Section titled “Performance”- A batch
ACKBthat carries results (what a TCPWorkersends whenever its processor returns a value) now commits once per batch instead of twice per job. It runs the samestoreResultandmarkCompletedstatements, with the same arguments and in the same order, inside one SQLite transaction, stops at the first failure exactly where the per-job loop stopped, commits the writes that succeeded and replays each outcome through the same disk-full bookkeeping, so every outcome, including the 2.9.11 fault matrix and lock-contention behavior, is unchanged. A job still in the write buffer or a failedBEGIN/COMMITkeeps the per-job writes. Before, those two autocommits per job took about 60% of the broker’s CPU while processing. On native macOS arm64 with Bun 1.4.4 (fresh broker and SQLite database per run, 50,000 jobs, worker concurrency 50 and batch size 50, three alternating runs), TCP processing went from 6,825–6,892 to 13,176–13,266 jobs/s (1.93x); push and bulk push were unchanged. These are native host diagnostics, not publication benchmarks.
Bug fixes
Section titled “Bug fixes”- A flow parent no longer waits forever when a child is exhausted by a
crash, an expired lock or a stall. A child with
failParentOnFailure,ignoreDependencyOnFailure,removeDependencyOnFailureorcontinueParentOnFailurethat used up its attempts (ormaxStalls) outside a worker FAIL — during startup recovery after a broker crash (“Job reached max attempts (N) during startup recovery”), on lock expiry (“Lock expired at max attempts (N)”) or through stall detection — went to the DLQ, but its policy never ran: noflow_failuresrecord was written and the parent stayed inwaiting-children. These paths now apply the policy as a terminal worker FAIL does: the outbox record commits in the same SQLite transaction as the DLQ move, so a crash right after it cannot lose the policy, and the parent is failed, promoted or detached in memory (or by the startup outbox replay), with the same childflow:faileddashboard event (the other worker-FAIL bookkeeping, such asdlq:addedandtotalFailed, is unchanged on these paths). Jobs without a parent or without a policy are unchanged, as are retry, backoff, stall counting, DLQ reasons and messages, and thecron:discard. Parents already stuck by this bug before the upgrade are repaired at the next startup (see the startup repair below). Found by a production-style FlowProducer campaign (50,100 jobs with broker SIGKILLs) that left 21 parents inwaiting-children; PostgreSQL mode was not affected, because its lease expiry already applies the policy in the same transaction. (test/repro-flow-policy-on-recovery-exhaustion.test.ts,test/flow-policy-recovery-durability.test.ts,test/flow-policy-recovery-edges.test.ts) failParentOnFailurenow propagates up a nested flow. A parent that a child’sfailParentOnFailuremoves tofailed(“Child jobfailed: …”) has failed terminally itself, but its own policy toward its parent never ran: with grandparent <- mid(failParentOnFailure) <- leaf(failParentOnFailure), a failingleaffailedmidand leftgrandparentinwaiting-childrenforever, and the same happened whenmidcarriedignoreDependencyOnFailure,removeDependencyOnFailureorcontinueParentOnFailure. The failed parent’s own policy now applies to its parent, level by level up the tree, as BullMQ does: itsflow_failuresrecord commits in the same SQLite transaction as its DLQ move, the effect runs after the shard lock is released, and the startup outbox replay propagates the same way, so a crash between “mid failed” and “grandparent updated” cannot strand the grandparent. A failed parent without a policy or without a parent keeps exactly the previous writes and events (its own parent keeps waiting); siblings are not cancelled, the parent’s attempts are not consumed, and DLQ reasons and messages are unchanged. PostgreSQL mode propagates the same way: the failed parent’s own record commits inbunqueue_flow_failureswith its failure (pending while the next parent’s dependency edge exists), and each next level runs in its own transaction after the commit, with the locks of a terminal child failure, so the lock order stays deadlock-free. Brokers serialize on the failed parent’s dependency-completion lock, so each level applies exactly once, and the recovery sweep of any broker (at startup and on every lease-recovery tick) finishes a chain that a crash interrupted between two levels. A pendingignore/continuerecord is not reported bygetIgnoredChildrenFailures()orgetFailedChildrenValues()until it is applied. The record is written only while that edge exists, and one whose parent a sibling failed first is dropped, so a failed parent never reports a phantom ignored or continued failure. Unlike SQLite, a sibling’s failure orcontinueParentOnFailurethat commits between two levels wins, and the lower level’sfailrecord is then dropped. The sweep pages through every pending record, so records that keep failing cannot starve the others, and its error names the failing parent/child pair. No schema change. During a rolling upgrade, older brokers do not apply the next level or run the sweep (their chains stop at one level, and a mid-level parent they fail writes no record; the startup repair below writes it on the next start of a newer broker), and their reads can briefly report a pendingignore/continuerecord. Parents left waiting this way before the upgrade, on SQLite or PostgreSQL, are repaired by that startup repair. (test/repro-flow-fail-parent-propagation.test.ts,test/flow-fail-parent-propagation-durability.test.ts,test/postgres-flow-fail-parent-propagation.test.ts,test/postgres-flow-fail-parent-propagation-durability.test.ts,test/postgres-flow-fail-parent-propagation-multibroker.test.ts,test/postgres-flow-fail-parent-propagation-edges.test.ts,test/postgres-flow-propagation-stale-record.test.ts)- PostgreSQL: no phantom flow-failure value on a completed parent, and a
bounded recovery sweep. A propagated
ignore/continuerecord could be written while the next parent’s edge was still visible, after which a sibling’scontinueParentOnFailurereleased that parent and it ran and completed before the record committed; the completion had already cleared the parent’s values, sogetIgnoredChildrenFailures()/getFailedChildrenValues()on the completed parent reported a phantom failure. A record without its edge on acompletedparent is now dropped by the applier and the recovery sweep, as one on afailedor missing parent already was. The sweep also no longer clears a whole backlog in one run: each run (startup or one lease-recovery tick) reads at most 10 pages of 100 records, and each broker keeps its cursor in memory, so the next run continues where the previous one stopped and wraps around to older records; broker readiness no longer waits on an unbounded startup sweep. A crash backlog drains at up to 1,000 records per tick (every 15 s with the default 30 s lease) on each live broker. (test/postgres-flow-propagation-completed-parent.test.ts,test/postgres-flow-propagation-sweep-budget.test.ts) - Flow parents stuck by the two bugs above are repaired at startup. Older
versions could leave a parent in
waiting-childrenforever with a dependency edge on a child that had failed terminally (in the DLQ) carrying a failure policy, and noflow_failuresoutbox record: a child exhausted by startup recovery, lock expiry or stall detection, or a mid-level parent failed by its child’sfailParentOnFailure(“Child jobfailed: …”). Startup now writes the missing record, in the shape a terminal failure writes, with the DLQ entry’s error and the time of the child’s last failed attempt, and the existing outbox replay applies it: the parent is failed, released (with the entry in getIgnoredChildrenFailures()) or promoted (with the entry ingetFailedChildrenValues()), and a repairedfailkeeps climbing the tree. The repair is idempotent (an applied record removes the edge or fails the parent, and an existing record is never replaced) and it only touches a parent still inwaiting-childrenthat stayed there since the child failed. It never touches a child without a policy (the parent keeps waiting, the documented default), a child still retrying or queued, a discarded or quarantined child (DLQ reasonunknownwithout a “Child job” error: those paths never applied a policy), a parent that was running when the child failed (no version applies a policy to a running parent), or a parent that someone acted on after the failure: any timeline transition at or after the failure other thanwaiting-children(a DLQ retry, a run, a promotion), or alastHeartbeatat or after it (a DLQ retry and a pull both stamp it, so a parent retried from the DLQ and given a new child before it ran keeps waiting for that child even when its 20-entry timeline is full). On SQLite the scan runs once in memory over the recovered waiting parents, before the outbox replay; the log names the repaired pairs (the first 20) and their count, and a write error stops startup as the replay’s own writes do. On PostgreSQL every broker process makes one full pass overbunqueue_dependenciesin primary-key order: one slice of at most 1,000 edges at startup, then one slice 250 ms after the previous one finished until the last edge, and a restart starts a new pass. About 4,000 edges/s per broker is a ceiling (100,000 edges take at least about 25 s); a slice holds one pool connection while it runs, slices never overlap, and every broker makes its own pass, so ten brokers restarting on a 1,000,000-edge table each spend at least about 4 minutes of reads. Every match is re-checked under the locks of a terminal failure of that child and gets its record withON CONFLICT DO NOTHING, so it applies exactly once across brokers; the existing applier then applies it, and the recovery sweep finishes it after a crash. After a clean pass the broker stops scanning and no repair timer stays armed; a pass with errors starts over after 60 s, doubling after each further failed pass up to one hour, and its error (including a database error that interrupts a slice) stays in themaintenance:flow-failure-repairhealth entry until a pass completes cleanly. A candidate row that cannot be decoded fails only its own edge instead of stalling the pass. A parent that an older broker strands later in a rolling upgrade is repaired by the startup pass of the newer broker that replaces it. No schema change; a child removed on failure (removeOnFail) or purged from the DLQ leaves no trace to repair from. (test/repro-flow-legacy-stuck-parent-repair.test.ts,test/flow-legacy-stuck-parent-repair-controls.test.ts,test/repro-flow-legacy-repair-retried-parent.test.ts,test/postgres-flow-legacy-stuck-parent-repair.test.ts,test/postgres-flow-legacy-stuck-parent-controls.test.ts,test/postgres-flow-legacy-repair-coverage.test.ts,test/postgres-flow-legacy-repair-maintenance.test.ts) - A parent failed by several siblings at once reports its failure once. When
two or more children with
failParentOnFailurefailed at the same moment under the same parent, the parent went to the DLQ once (and its own parent was handled once), but itsfailedevent and the parent-levelflow:faileddashboard event were emitted once per sibling, each with a different reason (“Child jobfailed: …”, “Child job failed: …”), so webhooks, event subscribers and waitUntilFinishedcallers saw it fail more than once. The sibling whose call finds the parent already moved (the re-check inside the shard lock) now emits nothing and leaves the parent’s in-memory bookkeeping alone; only the call that moved the parent reports it, with that child’s reason. A plain two-level parent with two failing children had the same duplicate, before and after the propagation fix above. The startup outbox replay emits no events and was not affected; PostgreSQL mode was not affected. (test/flow-coverage-concurrent-siblings-engine.test.ts,test/flow-coverage-concurrent-siblings.test.ts)
Tooling
Section titled “Tooling”- Type checking uses Bun’s built-in
bun check(bun run typecheck), and formatting uses Bun’s built-inbun fmt(bun fmt,bun run format,bun run format:check), configured in the[fmt]section ofbunfig.toml. Afmtscript keeps a barebun fmtinside the formatted scope. This applies to the root project and the TypeScript SDK (sdk/typescript). oxlint,oxfmtandoxlint-tsgolintare removed from both projects, together with.oxlintrc.json,.oxfmtrc.json, thelint,lint:fixandcheck:oxcscripts, theoxlint-disablecomments, and thetinypooloverride that onlyoxfmtneeded. Linting is dropped;typescriptstays forbuild:libdeclaration output, typedoc and the compiler-API tests.bun fmtreformatted 13 root and 1 SDK source files (union types that fit on one line, multi-line SQL template arguments); no code changed meaning.src/infrastructure/cloud/wsSender.tsnarrows theBun.zstdCompressframe toUint8Array<ArrayBuffer>beforeWebSocket.send, a type-only fix for an error thatbun checkreports andtsc5.9 did not. There is no copy and no runtime change.- The 46 leftover
eslint-disable/eslint-enabledirectives insrc/andtest/are removed too: no ESLint runs in this repository. The two that explained the code keep their explanation as a plain comment. bun fmtnow also coverstest/,scripts/,bench/,examples/andsdk/conformance(227 files reformatted, layout only), andbun run typechecknow checkssrc/mcp(excluded since its dependencies were missing), and the SDK workflow type-checkssdk/conformanceafter building the SDK it imports; both were already free of type errors. Generated benchmark reports underbench/vs-bullmq/results/are excluded from formatting.CLAUDE.md,AGENTS.md,CONTRIBUTING.md, the contributing guide, the TypeScript SDK rules and the agent profiles now state the mandatory rule:bun checkandbun fmtare the only type-check and format tools, in every directory and SDK.tscstays only where files are emitted, for typedoc and for compiler-API tests, and non-JS SDKs keep their language’s own toolchain.- The test tree is now type-checked:
bun run typecheckalso runsbun check -p tsconfig.test.json. 1,008 of the 1,039 type errors intest/(and thebench//scripts/helpers the tests import) are fixed with type-only changes: annotations, type arguments, casts on test doubles and branded ids, removed unused imports and bindings (each call kept), and_for unused parameters. Of the 288 changed files, 233 transpile to identical JavaScript, 8 differ only in whitespace, and 47 differ only by dropped unused bindings (each call kept), renamed unused parameters, narrowed destructuring of the same awaited value, removed side-effect-free expressions and one import alias; no test does or asserts anything different. The 31 remaining errors are genuine test defects that only a behavior change can fix, so their 15 files were excluded by name intsconfig.test.jsonat first. - Those 31 test defects are now fixed, so no test file is excluded from type
checking:
- options the engine silently ignored:
attempts→maxAttempts(the stale ack timeout repro now really runs with 2 attempts),jobId→customId(the CLI read invariants now read real jobs instead of “not found”; their fixtures were also swapped so thecompletedjob is the one acked),maxRetries→maxAutoRetries,{ race: true }→{ data: { race: true } }; getJobs([...])→getJobs({ state: [...] }), so the restart and dedup-recovery checks filter by state (they still pass: a delayed job stays delayed after a restart);- no-op arguments removed:
writeBufferFlushMspassed toQueueManager(not a config field) andserver.stop(true)(stop()takes none and already force-closes); - write-only locals are now asserted: the bug-15 worker event test checks
each event’s typed arguments with
expectTypeOfand their runtime values, issue 82 checks themoveToCompleted()result, and issue 34 re-reads the first chain’s parent result after the flood. The issue 34 floods now exceed the realmaxJobResults(they used 5,100 against a 10,000 cache, so nothing was evicted) and run on SQLite: the parent-result test now fails if an evicted result is not read back from storage (in memory-only mode results are bounded by design). ThegetChildrenValuesflood test still does not reach the storage fallback, because the children’s results stay in memory; Job.updateProgress(and its handler types) now acceptnumber | object, as the runtime and the docs already did; the emitted JavaScript is unchanged. The Workerprogresslistener keeps itsnumberparameter so existing(job, p: number) => …listeners still compile; a listener that receives object progress can be annotatednumber | object.test/tsconfig-test-exclusions.test.tsfails iftsconfig.test.jsonever excludes a file or weakens its compiler options.
- options the engine silently ignored:
- The CI Type Check job failed since the test tree became type-checked: the
tests import generated output that only a developer checkout had (the
portable SDK build, the package build for the examples’
bunqueue/clientimports, and the docs dependencies). The job now runs the newbun run typecheck:preparefirst; run it locally on a fresh clone and after changingsrc/orsdk/typescript/, so the check never reads stale builds. - The Go SDK e2e harness no longer fails after 15 s when another process takes
its port between
freePort()and the server’s bind: it binds127.0.0.1, detects the exit at once and relaunches on fresh ports. bun run typechecknow runstypecheck:prepareitself (about 4 s; its package build istsc --noCheck, emit only, so type checking stays withbun check), so a local run or the pre-commit hook can no longer check the test tree against a stale SDK or package build; the CI Type Check job just runstypecheck.- CI treats TypeScript and JavaScript under
docs/as code: a change that touches onlydocs/src/lib/*.ts(which tests import) now runs the type check and the test jobs instead of skipping them as a docs-only change. test/tsconfig-test-exclusions.test.tsalso fails if any TypeScript file under the type-checked folders contains a nocheck or ignore directive (it reads the files from disk, so it also works where there is no.git).- The Docker image build type-checks
srcwithbun check --no-script: since the test tree became type-checked, itsbun run typecheckstep failed, because the image holdssrc/only. The image is built only for releases, so no published image was affected. - The Go e2e harness relaunches only a server that exited on a bind collision
(not one that timed out), and sets
cmd.WaitDelayso a child holding the output pipe cannot block a crash or stop. - The standalone TypeScript SDK’s legacy
Job.updateProgress(and its progress hook) acceptnumber | object, like the main client; the broker already normalized objects the same way (0, with the object’s JSON as the message when none is given), and the emitted JavaScript is unchanged.
Documentation
Section titled “Documentation”-
The 2.9.21 release post is now a full write-up:
- a diagram of the event traffic workers received before and after the
eventTypesfilter; - a byte-level diagram of a corrupted
Float32Array, and the fact that its stale bytes could carry another job’s data; - how a CPU profile found the event traffic, and how a call-count matrix (169 functions by 19 scenarios) confirmed both changes;
- how the counting
moreTypesgetter keeps ordinary frames on the one-pass path; - why msgpackr 2.1.3 was not adopted.
The throughput figures are the ones already published, measured on the final 2.9.21 code.
- a diagram of the event traffic workers received before and after the
Testing
Section titled “Testing”bun testandbun run test:modelon a developer machine no longer run the stale test copies underartifacts/:bunfig.tomlexcludesartifacts/**. On one developer machine,bun run test:modelran 150 files instead of 6 and reported failures from old benchmark snapshots.- Server-startup tests that launch through
runServer(test/config-test-support.ts) no longer fail when a parallel test worker takes a port betweenfreePort()and the server’s bind.runServernow relaunches with fresh ports after a bind collision, unless the test pinned a port itself. - The TCP and embedded integration runners now count the assertions of scripts
that print
Results: …summaries, through a sharedparseTestSummary.test-websocket.tswas reported as 0/0 instead of 47 passed. AtotalFailed: Nmetric printed before a summary is no longer read as the failure count.
[2.9.21] - 2026-10-31
Section titled “[2.9.21] - 2026-10-31”Performance
Section titled “Performance”- TCP workers no longer receive every event of their queue. A TCP Worker
subscribes to its queue’s events only to report
stalledjobs, yet the broker encoded and wrote, and every worker process decoded and dropped, thepulledandcompletedevent of each job.SubscribeEventstakes an optionaleventTypesfilter (1 to 32 event type names) and the Worker asks forstalledonly: the broker skips encoding an event no subscriber of its queue accepts, a filtered connection gets only its accepted frames (in the same order), and an unfiltered connection, such asQueueEvents, gets exactly the bytes it got before. A broker without the filter ignores the field and sends every type, which the Worker still filters itself, and a Worker from an earlier release still receives every type from a new broker. Draining 200,000 prefilled jobs in memory mode over TCP (50 concurrency per worker process, batch size 20, Apple M5 Max, macOS 27.2, Bun 1.4.4, alternating fresh brokers): one worker process went from 76,329–78,216 to 87,722–89,644 jobs/s (+15%), four from 97,370–99,671 to 134,102–135,074 (+37%), and eight from 79,970–82,334 to 115,737–129,962 (+51%). With SQLite the drain is bound by disk writes and moves by under 1%. These are diagnostic host results, not publication benchmarks. - Single-job completions on SQLite do less work. Every single-job
completion and failure (
ACK,FAILand the embedded Worker) deletes the job’s parent-sideflow_failuresrows; that statement went throughdb.prepare(), which compiles it on every call (10,000 compilations for 10,000 jobs), and now goes throughdb.query(), which reuses one compiled statement (the one job deletion and replacement already used). The flow-completion check after each single-job completion read the job back from SQLite and decoded it only to readparentId; it now reads the job the completion has just retained in memory, settling on the same microtask as the old lookup, and keeps the old lookup when the job is not retained. Rows, job events and the order of dashboard events are unchanged (checked on a three-child flow acked concurrently, in memory and on SQLite). Draining 20,000 jobs with an embedded Worker on SQLite (50 concurrency, batch size 20, Apple M5 Max, macOS 27.2, Bun 1.4.4, four alternating fresh-process samples per version) went from 4,712–4,757 to 4,942–4,969 jobs/s, about 4% faster; memory mode is unchanged. These are diagnostic host results, not publication benchmarks. On the final 2.9.21 code (with the typed-array fix below) the same embedded SQLite drain moved by -0.9% to +5.8% at 10,000 to 1,000,000 jobs, with overlapping ranges.
Bugfixes
Section titled “Bugfixes”queue.getDelayedCount()in embedded mode counted the jobs of one defaultgetJobspage, so it never reported more than 100: a queue with 250 delayed jobs answered 100 whilegetJobCountsAsync().delayedanswered 250. It now reads the same counts asgetJobCounts(), as over TCP and as the other per-state count helpers do. (test/repro-embedded-delayed-count-cap.test.ts)- A simple-mode (
Bunqueue) processor now learns that its job ended elsewhere: a broker-enforced jobtimeout,app.worker.cancelJob(id),cancelAllJobs()and a forced worker close abortapp.getSignal(job.id)with the Worker’s reason and stop any pending in-process retry. The wrapper dropped the Worker’s signal, so the processor kept running after the broker had already timed the job out. When the processor honours that abort, the rejection counts as a failure for the circuit breaker, asapp.cancel()already did, so cancelling many running jobs withcancelAllJobs()can open the circuit. (test/repro-bunqueue-worker-signal.test.ts,test/bunqueue-cancellation-parent-signal.test.ts) - Typed arrays in job data no longer arrive corrupted. A
Float32Array,Uint16Array,Float64Arrayor any other non-byte TypedArray in job data, results, progress or events reached workers over TCP, and SQLite readers after a restart, as a Uint8Array of the wrong bytes; aDataViewarrived as an empty one, and aBigInt64Arraymade a TCP or durable add throw (an embedded buffered add succeeded, then its write-buffer flush failed). msgpackr writes such a view as a msgpackbinof its byte length but copies it element by element, leaving stale bytes of its shared output buffer in the frame (the bytes changed from run to run).encodeMessagePacknow writes such a view as abinof exactly its own bytes wherever it sits in plain objects (includingObject.create(...)objects), arrays, Maps, Sets and Errors, so readers get exactly those bytes (aUint8Arrayin JavaScript). Class instances (with or without atoJSON) and subclasses of those built-ins are left to msgpackr exactly as before, and so is a built-in type an app encodes with its ownaddExtension, detected with an empty sample of that type (an extension that writes the built-in bytes for an empty value is not detected); a view inside one keeps the previous encoding. The rewrite visits each value once, so deeply nested Sets and Maps a client sends cost linear time. Frames without a view are byte-identical to before (53,547 compared values); frames with no Set, Error, RegExp, ArrayBuffer or non-Buffer binary value are encoded in one pass at unchanged cost, a frame that carries a plainUint8Arraypays one extra scan (about 9%), and one with a Set or Error also packs an empty sample of that type to check for an app extension. A frame that needs the rewrite is encoded twice, so atoJSONinside it runs twice. Rows already stored with corrupt bytes are not repaired. The TypeScript SDK (bunqueue-client) used to refuse these views withSerializationErrorand now sends them the same way. (test/repro-typed-array-payload-bytes.test.ts,test/msgpack-byte-views.test.ts)
Documentation
Section titled “Documentation”- The
bunqueue-assistantagent (agents/bunqueue-assistant.md) now carries an API matrix of every public surface, checked against the source: Queue and its options, job options, the Job object, QueueEvents, QueueGroup, Worker and its options and events, SandboxedWorker, simple mode, flows, store-and-forward, errors and helpers, the workflow engine,bunqueue/agents, every MCP tool, resource and prompt, the 107 TCP commands, the HTTP endpoints, the CLI and the server environment variables, each with what it does and how it behaves embedded and over TCP, plus the known caveats found while checking. Its MCP count now reads 86 tool registrations instead of 75. - A new Benchmark vs BullMQ page reports bunqueue 2.9.20 against BullMQ 6.4.2 on Redis 8.10.2 (ioredis 6.0.0; BullMQ on both Node.js 24.22.1 and Bun 1.4.4), in memory and on disk, with the machine (AMD Ryzen 9 7950X3D, 124 GiB, NVMe RAID 5, Debian 13, bare metal), the method and every number as the median of five runs with its range. In memory bunqueue adds jobs 1.3–4x faster, drains a backlog 1.1–1.3x faster, retries 1.3x faster and starts delayed jobs 8.5x (median) closer to their due time; median pickup latency is level and BullMQ has the lower p99 at 5,000 and 10,000 jobs/s. On disk Redis with AOF is ahead: about 7x on draining a backlog, with bulk adds level. With a million queued jobs bunqueue holds about 2.2 GiB and Redis about 0.6 GiB.
- The homepage has a benchmark section right after the hero: the test machine, the four largest measured ratios, one chart per unit (bunqueue and BullMQ bars scaled to the same axis, values at the bar ends, the five-run range on hover or focus), a table view and the on-disk result in the fine print. The bar colors pass the palette validator (CVD separation and contrast) in light and dark.
- The comparison guide, the benchmarks guide, the July benchmark article (now
flagged with the newer results) and
llms.txtuse the new measurement instead of the older BullMQ 5.79.3 figures.
Benchmarks
Section titled “Benchmarks”bench/vs-bullmqis a reproducible harness for the comparison: pinned package versions, the broker and the clients on separate CPU core complexes, one broker at a time, a warm-up and five measured runs per scenario in fresh client processes, open-loop pickup latency measured from each job’s scheduled send time, and an exact-delivery check on every consuming run.src/export.tsandsrc/page.tsturn a run into the site’s data file and the benchmark page; the raw results of the published run are committed underresults/.
[2.9.20] - 2026-10-25
Section titled “[2.9.20] - 2026-10-25”Performance
Section titled “Performance”-
Throughput falls far less as TCP worker processes are added. Every TCP Worker subscribes to its queue’s events (to report
stalledjobs), and a pull or ack batch emits one event per job; the server wrote each event to each subscriber with its own socket write, so the writes per job grew with the number of worker processes and took about 64% of the server’s CPU with four of them. Each subscriber now receives a batch’s frames for its queue as one write, in the same order and with the same bytes. Draining a backlog over TCP (memory mode, 50 concurrency per worker process,pollTimeout: 5000, server pinned to two cores, workers on separate cores, median of three runs):Worker processes 2.9.19 Now 1 33,080 jobs/s 39,735 jobs/s 4 18,152 jobs/s 53,102 jobs/s 8 10,151 jobs/s 40,940 jobs/s Single events (outside a batch) are written as before, and an event that cannot be encoded is still skipped alone. A connection whose write queue goes over budget is still closed at the same point; only the byte count in the warning it logs can be larger. Measured in memory mode on a native Linux server (Debian 13, AMD Ryzen 9 7950X3D, Bun 1.4.4); SQLite mode uses the same event path, while the PostgreSQL backend broadcasts events one at a time and is unchanged.
Documentation
Section titled “Documentation”-
Documentation pages use one professional type system: titles, headings and body share Inter (the condensed display face stays on the homepage), with a calmer scale (page title 2-2.6rem, h2 1.55rem, h3 1.2rem), body copy one step below the heading color, a 66ch measure and consistent spacing above headings. Code uses IBM Plex Mono everywhere: inline code and the
--sl-font-monostack named JetBrains Mono, which the site never loaded. Heading anchors and code copy buttons stay quiet until hovered or focused, sidebar badges share one style, table headers are small caps, and long “On this page” entries wrap instead of running off the screen (the panel was sized from the 18rem left sidebar). On phones, prose, headings, code and callouts share one 20px gutter; paragraphs and headings used to sit 1rem further in. -
The homepage uses the same typeface: every headline is set in Inter with tight tracking instead of condensed Bricolage Grotesque, which the site no longer loads (the font package, its preload and its CSS are removed). The hero keeps one announcement, the newest release post (the Academy and AI agents pills only jumped to the sections below it), sets the second sentence of the headline in the muted color, and puts the actions under the subtitle. FAQ questions open with a chevron on the right, the footer links are set in Inter rather than monospace, code copy buttons match the documentation, and the homepage no longer shows “Edit page” or “Last updated”.
-
The homepage said the MCP server has 75 tools; it has 79 primary tools in 11 toolsets, as the MCP guide and
llms.txtalready said. -
/llms-full.txtand the Markdown twin of every page (/<page>.md) are now plain Markdown an AI agent can read without the site’s presentation layer. They used to carry the MDX source verbatim: outside code,llms-full.txtalone had 97importlines, 2,272<Tabs>/<TabItem>tags, 957 layout<div>s, 1,448classattributes and 334 hero blocks; it now has none. A new converter (docs/src/lib/agent-markdown.ts) removes imports, exports, MDX comments and layout HTML, turns each tab into a bold label above its content (Bun, Python, …), asides and:::notedirectives into blockquotes, cards and link cards into titles and links, and inline HTML into Markdown; the decorative page hero keeps only its summary, interactive widgets leave a one-line note, and root-relative links become absolute. Code blocks are never touched: every fenced example (1,591 in the page sources, 3,312 fence lines inllms-full.txtbefore and after) comes through byte for byte, blank lines included. -
llms.txtlists every documentation page (114, up from 62): new Queue, Worker and Scheduling sections, the eight TCP command families, the PostgreSQL multi-broker example pages, the SDK overview, the glossary, the production guide and the versioned API reference. It explains that every page has a.mdtwin and that Queue and Worker must share a mode, and its quick example now creates the worker withembedded: true; without it the worker connected over TCP and never saw the embedded queue’s jobs. -
The blog has two RSS feeds: every post (
/rss.xml) and release posts only (/blog/releases.xml), both auto-discoverable. Items carry a category (Release or the article topic), the post author and the publication date; same-day releases are ordered by version, andlastBuildDateis the newest post date rather than the build time. The blog index shows RSS, releases and GitHub releases links under its title, and every post links the feed. -
Every release now has its own blog post at
/blog/bunqueue-vX-Y-Z/, in the format of Bun’s release posts: a one-line summary, install and upgrade commands, one section per new feature, then the remaining changes with bugfixes last, the contributors and a link to the previous release. All 256 dated releases have a post, generated from this changelog bydocs/scripts/generate-release-posts.ts; contributors, GitHub releases, npm versions and Docker images come fromdocs/src/data/releaseMeta.json(--refresh). The 2.9.19 post is written by hand, and the generator never overwrites a post without its generated marker. -
The blog index follows Bun’s layout: the latest post as a featured card, Everything / Articles / Releases filters, and posts grouped by year. The home page announces the newest release post.
-
Release posts use the release date as their sitemap
lastmod(not the date their file was regenerated), with their own priority and change frequency; the blog index is listed at a higher priority. Every post has its own description. -
CLAUDE.md,AGENTS.mdand thebunqueue-devskill require a professional, hand-written blog post for every release. The skill’s outdated publishing steps (bun publishafter every commit) are replaced with the trusted publishing rules.
- Pull requests and pushes that only change the docs site (
docs/), root Markdown files or.claude/no longer run the product suites (lint, typecheck, unit, TCP, embedded, core E2E, WebSocket, PostgreSQL and SDK jobs). A newDetect changesjob classifies the diff; the docs job still builds the site and runs the tests that read the docs, README and agent guides. The quality gate accepts skipped product jobs only for docs-only changes, and manual runs always run everything. - On docs-only changes the docs job also runs tests that locate the docs
through path segments (
join(root, 'docs', 'src')), such as the check that theme rules skip.not-contentwidgets. - The docs job checks that the release posts match the changelog
(
generate-release-posts.ts --check). - GitHub releases are titled
bunqueue vX.Y.Zand their notes open with a link to the release’s blog post.
[2.9.19] - 2026-10-25
Section titled “[2.9.19] - 2026-10-25”Algorithmic work on the broker’s data structures. Public APIs, options, events,
error messages and results are unchanged, except that getJobs on very large
queues now returns its page instead of throwing (see Fixed). The existing test
suite passes unmodified, and a differential check that drives the previous and
the new engine with the same random operations returned identical answers in
150,000 comparisons (three runs of 50,000). Workloads with immediate retries are
excluded from that check: the order of two equal-priority jobs retried in the
same instant depends on the failure millisecond, so it also differs between two
runs of the previous engine. Most gains appear with large backlogs, many connections or
large messages; with small queues the hot path is unchanged to slightly faster.
Figures below are diagnostic measurements (Bun 1.4.4, Linux x64, AMD Ryzen 9 7950X3D, memory mode unless noted), taken with the same script on the previous and the new code. They are not results of the official benchmark suite.
-
getJobs(andgetWaiting,getDelayedand the other list helpers built on it) no longer throwsRangeError: Maximum call stack size exceededon large queues. Collected jobs were appended withpush(...array), which passes every element as a call argument and fails past a few hundred thousand jobs. The same pattern is replaced in the paused, waiting-children and failed overlays of SQLite pagination, the cloud snapshot wait-time statistics and the Postgres DLQ statistics.Backlog State Before After 1M waiting, page 0..20 crash 96.9 ms 1M all states, page 0..20 crash 111.8 ms
Performance
Section titled “Performance”Pull and queue structures
Section titled “Pull and queue structures”-
Idle pulls on queues holding only future jobs. A pull scan parked every delayed job and restored it, so a worker polling a queue of scheduled jobs paid O(n log n) per poll while holding the shard lock. After a scan finds nothing due, the queue keeps a lower bound of when any job can become ready or expire, plus its earliest run time, and answers the next pulls directly with the same result (no job, same wake-up time). The tracker holds job ids only and is dropped as soon as a scan dequeues again, so queues with ready jobs pay nothing.
Queue (only delayed jobs) Before After 50k jobs, each pull after the first 19,597 µs 5.6 µs 10k jobs, each pull after the first 3,585 µs 3.7 µs 50k jobs, after a new delayed push 19,545 µs 0.7 µs -
Temporal index appends. Every push inserted into the per-queue skip list by walking it from the head and allocating a predecessor array. Inserts past the current maximum (the normal case) now link behind each level’s tail, and the predecessor array is reused.
Backlog Before After 1M 555 ns per job 192 ns per job 100k 404 ns per job 252 ns per job -
Common path, no delayed jobs (median of three runs):
push3.33 → 3.11 µs,pushBatch2.18 → 1.83 µs per job,pullandackunchanged. With a dashboard reading counts every 10 ms (incremental counters active),push,pushBatch,pullandackstay within ±7% of the previous code.
Counts, statistics and dashboards
Section titled “Counts, statistics and dashboards”-
Incremental queue state counts. Waiting, prioritized and delayed counts were recomputed by scanning every queued job on each request, and the SSE and WebSocket dashboards request them every 10 ms while events flow. Each queue now keeps these counts up to date from its first count read on, with a run-time heap (job ids only) that moves jobs from delayed to waiting as time passes, using the same classification as the scan; a read at an earlier time than the previous one rescans.
getStats,getQueuesSummary,getPerQueueStatsand the Prometheus exporter use the same counts. Completed counts in memory mode walk the bounded completed set instead of the whole job index. -
Incremental per-priority counts.
getCountsPerPriorityno longer scans the queue. Priorities that are not array-index keys (negative or fractional) keep the previous scan, so the returned object keeps the same key order. -
getJobscollection. Temporal jobs are collected and tagged with their state in one pass over the queue, without the intermediate arrays the scan used to build; the page is still sorted with the engine’s stable sort, in either direction.Operation (1M jobs) Before After Queue state counts (dashboard tick) 45.1 ms 0.01 ms getStats41.5 ms 0.72 ms getQueuesSummary68.2 ms 0.30 ms getPerQueueStats68.0 ms 0.26 ms getPrometheusMetrics108.8 ms 0.31 ms getCountsPerPriority39.1 ms 0.003 ms getJobsdelayed, page 0..2044.9 ms 28.2 ms getJobswaiting, page 0..20 (100k jobs)11.4 ms 8.1 ms
-
Frame parser. Server and client copied every buffered byte again on each socket read, so a large frame cost O(size² / chunk). Partial frames now accumulate in a buffer that doubles with the bytes actually received (never with a length announced in a header), and is trimmed back to the pending bytes after a large frame; a read with nothing pending is parsed in place. Frame bodies are still
Uint8Arraycopies, also when the socket hands over aBuffer, so decoded binary fields keep their type; buffered byte counts andFrameSizeErrorare unchanged.Frame (64 KB reads) Before After 64 MB 5,168.4 ms 64.8 ms 16 MB 277.2 ms 15.6 ms 1 MB 1.83 ms 0.85 ms 2,000 × 200 B frames, 512 B reads 0.171 ms 0.110 ms -
Event fan-out. Each job event scanned every connection to find the ones subscribed to its queue. Subscribers are now indexed per queue and delivered in connection order, as before.
Connections × subscribers Before After 10,000 × 1 26,258 ns per event 306 ns 10,000 × 100 29,940 ns per event 1,480 ns 1,000 × 1 2,493 ns per event 240 ns
Persistence
Section titled “Persistence”-
Write buffer index. Lookups, state overrides and removals of buffered jobs scanned both halves of the double buffer. Each half is now indexed by job id (first match, insertion order and flush contents unchanged).
Operation Before After Remove, 100k buffered jobs 491,230 ns 315 ns Lookup, 100k buffered jobs 120,424 ns 203 ns Pull / ack with flushing stalled, 50k buffered 269.8 / 283.2 µs 27.6 / 44.3 µs -
Cached SQLite statements. Statements on the retry, delete, active-dedup, waiting-children and flow-failure paths were compiled on every call (
db.prepare); they now use the statement cache (db.query).fail()with a retry goes from 118.8 to 80.7 µs per job.
Background maintenance and metrics
Section titled “Background maintenance and metrics”- The 10-second cleanup checks temporal-index orphans by asking the shard’s
queues directly instead of copying every queued id into arrays and a
Set, and the orphaned job-index sweep no longer allocates one object per entry. With 1M jobs, a cleanup tick goes from 504 to 405 ms and the empty-queue reconciliation from 343 to 184 ms. - The latency histogram records one bucket per observation and accumulates on
read:
observegoes from 12.5 to 9.2 ns.
- Pull requests from this repository get one automatic Claude Code review
(
claude-code-review.yml; fork pull requests, drafts and bot events are skipped), and repository owners, members and collaborators can ask Claude for help with@claudein issues and pull requests (claude.yml). Both workflows pin their actions to commit SHAs and have job timeouts.
[2.9.18] - 2026-10-23
Section titled “[2.9.18] - 2026-10-23”Security
Section titled “Security”- The weekly SDK advisory audit passes again for all six SDKs. It had failed
every week since 16 August: until 4 October at the Go step, which skipped the
Rust and Elixir audits, and on 11 and 18 October at the TypeScript step, which
skipped the other five:
oxfmtpins its worker pool,tinypool, to 2.1.0, which has two critical advisories; the repository and the TypeScript SDK now override it to a patched release. It is a development dependency only and neither published package ships it.- The Go toolchain used by CI, the SDK workflows and the SDK test image moves
from 1.26.5 to 1.26.10. The Go SDK’s TLS connection reaches four standard
library vulnerabilities fixed between 1.26.6 and 1.26.10 (GO-2026-6809,
GO-2026-6607, GO-2026-6090, GO-2026-5972). The module still declares
go 1.26.5; build applications with Go 1.26.10 or later on 1.26, or 1.27.3 or later. - The Rust SDK’s lockfile moves
rustlsto 0.23.46 andrustls-webpkito 0.103.16 (RUSTSEC-2026-0285, -0346, -0348, -0349 and -0382); rustls 0.23.46 also bringsaws-lc-rs1.18 andaws-lc-sys0.46. The existing dependency ranges allow all of them, so applications only needcargo update -p rustls -p rustls-webpki.
- The root package’s development dependencies no longer carry critical or high
advisories: with the
tinypooloverride above,bun auditdrops from 48 findings (3 critical, 19 high) to one low (@ai-sdk/provider-utils2.x, pinned through the locked@mastra/core1.53.0, whichbun audit fixcannot move). The lockfile now resolves patched releases inside the existing version ranges (bun audit fix;package.jsonis unchanged): the MCP SDK used to develop and testbunqueue-mcpmoves to 1.32.0, andproxy-addr,brace-expansion,fast-uri,hono,@hono/node-server,js-yaml,markdown-it,nanoidandqsmove to their patched releases, as doesip-address, to 10.7.4, which also fixes GHSA-rxp7-6hxx-x8q5, published on 22 October. The published package’s dependencies were already clean (bun audit --prod), and its optional@modelcontextprotocol/sdkpeer range is unchanged.
- With the PostgreSQL backend, a broker no longer briefly loses its own
committed writes from its local view
(#158). A full-queue
refresh that read PostgreSQL before the broker’s own commit could finish after
it, so a job the broker had just failed terminally could be reported
activeagain and be missing from its DLQ until the job’s event arrived; the same applied to its claims, completions, pushes, cancellations, DLQ removals and queue-wide operations. Each own commit is now recorded for such an in-flight refresh before the change is installed locally, so the refresh no longer overwrites it (see #161 below for how it is applied). Optimistic local settings that are applied before their deferred write commits (non-durablepause,resume, rate limit, concurrency, stall and DLQ configuration,drainandobliterate) can still be briefly reverted until the queue is reloaded. No data was lost, and claims were always decided in PostgreSQL. - With the PostgreSQL backend,
WaitJob(and sowaitUntilFinishedwithout QueueEvents) no longer returns the result of a completed job thatCancelhas since removed (#162). A broker receives a job’scompletedevent after its commit, so the event of the ACK could arrive after the Cancel; a WaitJob reading the job at that moment answered with the event’s result instead ofcompletedwithout a result. It now takes the result from the job’s completion record, which the Cancel cleared, as a waitingWaitJobalready did. Memory and SQLite are unchanged.test/postgres-cancel-outcome.test.tsfailed 8 of 50 runs under injected 0-15 ms scheduling delays before and 0 of 50 after. - With the PostgreSQL backend, a full-queue refresh of a busy queue now applies
on its first load (#161).
A refresh was discarded and reloaded whenever an event or the broker’s own
write for any job of its queue arrived while it loaded, so with steady traffic
it could be retried indefinitely without applying. Meanwhile the broker’s
local view missed queue-level changes made through other brokers (pause, rate
and concurrency limits, drain, clean, obliterate, DLQ pruning) and the
recovery after a startup event-buffer overflow, and the broker’s own
queue-wide durable operations, such as
drainDurableor a durable pause, could be missing from its view when they returned. A refresh now reads the journal position of its PostgreSQL snapshot and installs every job that did not change after it, leaving the jobs changed meanwhile to their own per-job refreshes, and an older refresh never replaces a newer one. Per-job state still converged, and claims were always decided in PostgreSQL. This also fixes the intermittent PostgreSQL test failure in which a broker still reported a queue as not paused right after another broker paused it during a burst of 256 pushes, and an ID-only update of a job the broker had not seen yet can no longer be briefly undone by an older refresh.
Testing
Section titled “Testing”- Two PostgreSQL event catch-up regressions no longer fail intermittently. They treated “event cursor at the journal head” as “snapshot updated”, which stopped being true when job events became asynchronous projection refreshes; they now also wait for queued refreshes to settle and for the expected DLQ. Under injected 0-15 ms scheduling delays the old wait failed 14 of 200 runs and the new one 0 of 200. The PostgreSQL design notes now describe the asynchronous refresh instead of a direct snapshot update.
- The TypeScript SDK’s cooperative-cancel end-to-end test no longer times out
intermittently. The broker counts a job active before the worker has read its
pull reply, and
cancelAllJobsonly cancels jobs the worker already holds, so the test could cancel one job instead of two; it now also waits until both processors have started. With a 5 ms stall after each count request the old test failed 31 of 50 runs and the new one 0 of 50.
[2.9.17] - 2026-10-22
Section titled “[2.9.17] - 2026-10-22”Changed
Section titled “Changed”- Release artifacts and CI run on Bun 1.4.4 (previously 1.4.3). The standalone
executables and Docker images are compiled with it, and the main CI, the SDK
workflows, the release builder image and the disposable test images pin it.
The npm package’s runtime requirement is unchanged (
bun >= 1.4.0).
[2.9.16] - 2026-10-15
Section titled “[2.9.16] - 2026-10-15”- MCP bulk retry confirmation cannot be bypassed with an empty job ID. ACK, failure and batch tools preserve ignored broker outcomes, and monitoring keeps valid prototype-like queue names.
- CLI boolean flags preserve explicit
false, argument terminators protect literal values, and job listing accepts every public state. DLQ output uses authoritative errors and timestamps; default waits allow the broker to finish. - Health checks produce structured JSON on success and failure, diagnostic URLs support IPv6, and progress aliases and worker guidance match supported APIs.
- Standalone MCP verification uses the maintained assertion suites with bounded subprocesses and isolated configuration instead of misclassifying tool outcomes.
- S3 retention preserves the newest timestamped backup when modification times tie. Retry decisions honor service error codes, and local deadlines cannot start overlapping uncancellable operations.
- SQLite restore makes committed WAL data recoverable before detaching sidecars, preserving the original state if the process is interrupted before installation. Busy or unsafe recovery refuses the replacement; corruption tests now reach the intended SQLite integrity check.
- Pull-cadence regressions check repeated scheduling decisions instead of assuming a fixed number of timer deliveries within a short wall-clock window. Changed test fixtures and their shared stream helper are checked against the actual API types.
[2.9.15] - 2026-10-15
Section titled “[2.9.15] - 2026-10-15”- Agent-oriented MCP inspection:
bunqueue_diagnose_queueaccounts for all queue states and reports observed limits and blockers,bunqueue_observe_jobdistinguishes completion, failure and unknown jobs with bounded polling, andbunqueue_list_jobs_pageprovides bounded offset pages with explicit projection and truncation metadata. Scoped queue/job resource templates avoid broad reads. - Structured MCP contracts: registered tools expose policy annotations,
output schemas and structured outcomes alongside existing text responses.
Dynamic discovery includes complete descriptors, and
bunqueue_get_capabilitiesreports effective server settings and tools. - Agent preset and readiness checks:
BUNQUEUE_MCP_PROFILE=agentenables dynamic discovery and destructive-operation confirmation when those settings were not explicitly supplied.bunqueue-mcp --doctor --jsonchecks configuration, paths and TCP readiness without starting consumers or opening workflow SQLite. - Optional
bunqueue/agentsentrypoint: provider-neutral turn consumption, a Claude message adapter and an independent SQLite journal support durable session checkpoints, bounded progress replay and stable external-effect keys. Pending effects require reconciliation before a retry; provider artifacts and cooperative cancellation remain application responsibilities. - Executable integration guidance and offline evaluations: Claude, OpenAI Agents and Mastra examples use caller/CLI model configuration. Five real-MCP scenarios run against embedded and TCP backends with independent state checks. Catalog measurements include schemas and annotations; deterministic task scores are not live-model success rates. Workflow/agent skills and public API references now expose the integration contracts.
- MCP diagnostic prompts omitted prioritized, paused and dependency-blocked work from their accounting and could imply an unobserved cause. They now report complete counts, observed limits and bounded samples.
- MCP write errors after a potentially committed operation could look safely retryable. Structured errors preserve uncertainty and require reconciliation; pre-dispatch refusals remain distinguishable from attempted writes.
- Agent examples could silently accept provider errors or interrupted streams,
share effect state between concurrent runs, or replace an explicit structured
nullresult with text. Typed terminal outcomes, per-run effect identity and output validation now preserve those contracts. - Documentation discovery validation accepts the independently validated
llms-full.txtresource while continuing to reject non-canonical page links and unknown text resources.
Compatibility
Section titled “Compatibility”- Queue scheduling, queue persistence and workflow engine behavior remain unchanged. Existing MCP listing/wait tools and text results remain available. The agents helper is optional and Bun-only, imports no provider SDK and uses its own database.
[2.9.14] - 2026-10-14
Section titled “[2.9.14] - 2026-10-14”waitUntilFinishedreturnedundefined, orJob <id> not found, for a job added withremoveOnComplete: true(#155). Over TCP withoutQueueEventsthe wait holdsWaitJobon the broker. A hold that saw the completion re-read the result from the completed-job store, where such a job keeps nothing, and answeredcompleted: truewith no result; a wait that reached the broker after the completion, with or withoutQueueEvents, rejected withJob <id> not found. In the issue’s request/reply script about half of the waits gotundefinedand the other halfnot found. A hold now answers with the result carried by thecompletedevent that wakes it, and memory and SQLite brokers keep the result of a job removed on completion in a short-lived, memory-only record (60 s, at most 1,000 jobs) that the broker’s completion lookup reads, so a wait that arrives within 60 s gets its result (unless more than 1,000 such jobs completed since). The record is never written to disk, and a new job under the same ID,remove(), obliterating the queue, a shutdown or a restart drop it. PostgreSQL already kept a completion record with the result and is unchanged.job.remove()on a completed job answered ok and removed nothing (#155). The broker’sCancelremoved only pending jobs, and the client ignored its refusal, so the job kept its data and result until retention while the caller believed it was gone.Cancelnow also removes a completed job: one SQLite transaction (one PostgreSQL transaction, which also deletes its logs) deletes its row and its result, then its in-memory projections and logs go. It keeps only the payload-free completion record thatremoveOnCompletekeeps, so a job that depends on it still runs. As for aremoveOnCompletechild, a flow parent that has not run yet still reads the removed child’s value from memory until a broker restart on SQLite; PostgreSQL drops it with the result. The bounded event journals (queue_events,bunqueue_events) keep the events already recorded, such as thecompletedevent with the return value, until their retention trims them; the PostgreSQLremovedevent carries no job data.removeAsync()and everyjob.remove(), FlowProducer jobs included, now reject when the job exists but was not removed, and still resolve when the job does not exist.WaitJobcould miss a completion during its initial asynchronous job read, before its completion waiter was registered (#155). The wait now captures the matching completion event before the first read and passes its result to the caller, including when removed-result retention is disabled. It registers the normal waiter before releasing that temporary subscription, and every return or error releases its subscriptions and timers.WaitJobandGetResultlost completed SQLite results after the completed-job cache evicted the job, even though the completed row and result remained on disk. Completion queries now read SQLite’s current state after a cache miss, including after a broker restart and for validundefinedresults. A cached result alone never proves completion, and a waiting, active or failed generation under the same ID suppresses stale results.WaitJobcould report a new waiting custom-ID generation as completed with anundefinedresult. An asynchronous job read could return a completed snapshot after another request reused its ID. The handler now confirms completion against the current generation and waits for the new job instead of trusting the old snapshot.- A queue concurrency limit ignored the jobs already running when it was
set.
setConcurrency(2)with one active job let two more start (three active), a job that was active before the limit freed a slot it never held when it finished, and clearing then setting the limit forgot the running jobs. Each job now holds its own slot, and a new limit counts the jobs already active. PostgreSQL already counted them. - A late ACK of a retried job freed a group concurrency slot it did not hold. A job that failed and was scheduled for a retry, then acknowledged by its first worker, released its group’s slot while another job of the group ran, so a group with concurrency 1 admitted a second job. Group slots are now owned per job, like queue slots: a release by a job that holds no slot does nothing. PostgreSQL never had this bug.
- Above 100,000 indexed jobs, the cleanup sweep could lose a job whose ACK
was in flight. The orphaned-index sweep deleted the
processingentry of a job between the two steps of its ACK: the ACK failed withJob not found, the job read asunknownuntil a restart, and its queue concurrency slot stayed taken. An entry is now removed only when a second sweep, at least 60 s later, still finds it orphaned. retryCompleted()kept the stall count of a job that stalled before it completed, so its next run reachedmaxStallsearly. It now resets it to 0, as a DLQ retry and the PostgreSQL engine do.- Startup recovery rewrote a recovered flow parent’s SQLite row from
waitingtodelayed. The job’s reported state was already right; the stored row now matches it.
Upgrade notes
Section titled “Upgrade notes”- Results of jobs removed on completion stay in memory for up to 60 s.
WaitJobon such a job answers{ ok: true, completed: true, result }where it answeredJob not found, so every SDK’s wait helper returns the result. The cap counts jobs, not bytes: with the default 1,000 jobs, results of 256 KB hold about 256 MB more heap than 2.9.13. Configure it withBUNQUEUE_REMOVED_RESULT_RETENTION_MSandBUNQUEUE_MAX_REMOVED_RESULTS,--removed-result-retention-msand--max-removed-results, orstorage.removedResultRetentionMsandstorage.maxRemovedResults;0in either turns it off and restores the 2.9.13 footprint. Embedded mode reads the same two environment variables, and a value the server would refuse (5s,1e12) makes the first embeddedQueueorWorkerthrow the same error.GetResultis unchanged and still finds nothing for these jobs. Cancelremoves completed jobs for every client:job.remove(),queue.removeAsync(), the fire-and-forgetqueue.remove(),DELETE /jobs/:id,bunqueue job cancel, the MCPcancel_jobtool, the dashboardjob:cancelcommand and the Python, Go, PHP, Rust, Elixir and TypeScript SDKs. Before, a completed job answeredok: falseand stayed. Active and failed jobs are still never removed.Cancelnames the state that refused it. A missing job still getsJob not found or cannot be cancelled; an existing job it does not remove now getsJob cannot be cancelled: not in queue (current state: active)(orfailed, orwaitingfor a PostgreSQL job that another live job depends on), without “not found”. The GoQueue.Removeand the PHPQueueControl::remove, which returnedfalsefor any “not found” reply, now return an error for those jobs and still returnfalsefor a missing one; the ElixirCommandError.not_found?/1is false for them. An MCP server of 2.9.13 still reports them assuccess: false. When the job moves into a removable state between the refusal and the state read (an active job that completes), Cancel tries again, at most three times in all; it targets the ID, so a job re-added under the same custom ID meanwhile can be the one removed, as with a Cancel sent a moment later.removeAsync()andjob.remove()reject for an existing job that was not removed: an active or failed job (theremove()of a DLQ job included), and on PostgreSQL a waiting job that another live job depends on. A processor that awaitsjob.remove()on its own job without catching the rejection now fails that attempt: the job is retried and, once its attempts are used up, moves to the DLQ, where 2.9.13 completed it after one run. A TCP FlowProducer job’sremove()on a missing job now resolves, as every otherjob.remove()does, instead of rejecting. Against a 2.9.13 broker, whose refusal is ambiguous, the client reads the job’s state and applies the same rule.- A concurrency limit set while jobs run admits nothing new until fewer jobs than the limit are active. 2.9.13 admitted new jobs at once and could run above the limit.
retryCompleted()resets the stall count, so a retried job gets its fullmaxStallsbudget again.- PostgreSQL: after a completed job is removed,
WaitJobon its ID answerscompleted: truewith no result, because the completion record stays without the result; memory and SQLite answerJob not found.
Testing
Section titled “Testing”- Issue #155 request/reply regressions exercise real Queue and Worker
instances in embedded and TCP modes, including object,
null,false,0andundefinedresults, payload and result deletion, explicit removal of retained results, and removal durability across a broker restart. Deterministic tests also cover completion-registration races, reused IDs, cold SQLite completions and waiter cleanup on failures. - Model campaigns.
BUNQUEUE_MODEL_SIZEandBUNQUEUE_MODEL_PATHreplay one generated history exactly; the command cap is the real bound only withBUNQUEUE_MODEL_SIZE=max(at the default size a history has at most 10 commands). A second campaign, weighted toward completing jobs, now exercises completion, failure, retry and cancel histories on every run; before, cancelling a completed job ran 0 times by default. The model now expects a completed retry to clear the progress message, as the engine and its documentation specify. - Crash recovery test.
CR6closed the client before theSIGKILL, so the broker sometimes returned the leased job to the queue first and the test saw 0 attempts (4 failures in 100 Linux runs). It now kills the broker first and waits for the job’sactiverow on disk instead of sleeping. - SDK sandbox. The Elixir test helper left six test brokers running after
every
mix test(about 55 MiB each); it now stops them when their owner exits. SDK suites now measure retained memory and surviving processes between two idle checkpoints around each suite command, so the growth signal no longer compares samples from different build and test steps.
[2.9.13] - 2026-10-09
Section titled “[2.9.13] - 2026-10-09”Data loss
- Re-adding a finished custom
jobId, or replacing a deduplicated job, withoutdurablecould lose both generations on a crash. With SQLite persistence the old generation (a completed job with its result, a DLQ entry, or the replaced deduplication owner) was deleted at once while its successor waited up to 10 ms in the write buffer; a crash in that window left neither on disk. The deletion and the new row now commit in one transaction foradd,addBulkanddeduplication.replace, including a replace while the owner is active, with or withoutdurable. These adds already paid that synchronous transaction, so they are no slower; plain adds stay buffered. PostgreSQL was already atomic. - An embedded
Queue.close()followed byprocess.exit()lost the last jobs it added. Buffered (non-durable) adds reach SQLite every 10 ms or every 100 jobs, andclose()/disconnect()returned without writing the rest: awaiting 10, 50 or 99add()calls, or oneaddBulk()of 42 jobs, thenclose()andprocess.exit(0)persisted none of them, and 150 adds persisted 100. Closing an embedded Queue now writes the write buffer before it returns. The flush is best effort, soclose()stays synchronous and never throws, and it respects the write buffer’s retry backoff: after a failed write,close()writes nothing until the scheduled retry runs, so closing queues never uses up the buffer’s ten retry attempts, and those rows are retried, and reported if finally lost, exactly as before.close()still leaves the process-wide manager and its timers running; callshutdownManager()to let the process exit. TCP queues and the PostgreSQL backend are unchanged.test/repro-embedded-close-exit-loses-buffered-jobs.test.ts,test/repro-embedded-close-respects-writebuffer-backoff.test.tsandtest/embedded-queue-close-flush.test.tspin the behaviour.
Graceful restarts
- A graceful restart no longer charges jobs that never failed. On SIGTERM
or SIGINT the TCP and HTTP listeners were closed before the active-job drain,
so a worker could not ACK the job it was running: the drain always waited the
whole
SHUTDOWN_TIMEOUT_MS(30 s, longer thandocker stop’s 10 s grace) and the job stayedactiveon disk. Startup recovery then charged it an attempt and a stall, so a job whose processor succeeded during shutdown wasfailedin the DLQ after restart (attempts: 1) or ran again (attempts: 3). Shutdown now first stops accepting connections and handing out jobs (PULL/PULLB, including long polls already waiting, return no job), keeps the open TCP connections and WebSockets served so workers can ACK, FAIL, heartbeat and report progress, and ends the drain as soon as no job is active. Only then does it close TCP and HTTP and shut storage down. Jobs still active at the deadline are left to the next process’s recovery, as before. During the drain a pull with a timeout delivers nothing and holds until its own timeout or until its connection closes, so a long-polling worker re-polls once per poll timeout instead of every 10 ms; a pull without a timeout returns empty at once. Other commands,PUSH/PUSHBincluded, keep working on open connections during the drain and are persisted. Plain HTTP requests need a new connection and are refused once shutdown starts. - A job released by a disconnecting worker keeps its attempts across a
restart. When a worker connection closed, its never-renewed job went back
to
waitingin memory with no attempt charged, but SQLite kept itactive, so a restart charged it (to the DLQ forattempts: 1). The release is now written to SQLite (waiting,prioritizedordelayed,started_atcleared, attempts unchanged), including for a job still in the write buffer. PostgreSQL already stored this release. - A job returned to its queue by a failed pull handoff keeps its attempts
across a restart. When a pull was cancelled or failed after the job had
already been stored
active(for example a throwingpulledlistener), the job went back to its queue in memory but SQLite kept itactive, so a restart charged it an attempt. The requeue is now written to SQLite as well, and the strayactiveentry that dequeue appended is removed from the job’s timeline (getJob().timeline). - With PostgreSQL, a cancelled pull claims nothing. A pull whose signal was aborted (its client disconnected, or the server started draining) while it waited for readiness, the write flush or admission could still claim a job; the signal is now checked right before every claim. A claim already running in the database when the signal aborts still completes and is delivered.
Python SDK
- Python SDK: closing a client now ends its TCP connection on Linux. The
SDK’s reader thread stays blocked in
recv(), and on Linux closing a socket that another thread is reading neither wakes that thread nor sends FIN. SoQueue.close(),Worker.close(), the context managers and every other close path left the connection open on the broker until the Python process exited. A job it had leased and not acknowledged stayedactiveuntil its lock expired (lockTtl, 30 s by default) instead of returning towaitingat once. With the graceful shutdown above, which keeps open connections served while it waits for active jobs, such a job held a server restart for the wholeSHUTDOWN_TIMEOUT_MS(30 s). That is how the Python SDK integration job in CI failed: its teardown gives the server 10 s to exit. Teardown now shuts the socket down, and only the reader thread closes it, once it has stopped reading, so the descriptor is never reused while a TLS alert can still be written through it. macOS was not affected, and the other official SDKs never close a socket while another thread is reading it. The fix ships in Pythonbunqueue-client0.2.1, published to PyPI by hand;sdk/python/tests/e2e_connection_close.pypins it.
Testing
Section titled “Testing”- The test sandbox image now ships
jq. Thedocker-sbomdigest test executes the CI workflow step, which pipes the image manifest throughjqas GitHub’subuntu-latestrunners provide it; insidebun run test:sandboxthe command was missing (exit 127), so that test failed on every sandbox run.Dockerfile.testinstalls it next toca-certificatesandopenssl. - The broker model’s crash restart now models a crash.
stopModelBrokerclosed the model client before sendingSIGKILL, so the broker could run the disconnect release first. Now that the release is persisted, a held job was charged or not depending on which event the broker saw first (test/repro-model-repeated-crash-stall-bound.test.tsfailed in the Linux sandbox and passed on macOS; with a 300 ms gap before the kill it failed 3/3). The broker is now killed while the client is still connected, and the client is closed afterwards.
[2.9.12] - 2026-10-08
Section titled “[2.9.12] - 2026-10-08”Performance
Section titled “Performance”Retried jobs no longer each carry their own hidden class: 60% less heap per
DLQ entry, and under sustained failures the broker holds 58% fewer strings and
28% fewer heap objects. A job’s retry history lives in a non-enumerable
Symbol property, added on its first retry and cleared when the job completes,
enters the DLQ or is retried manually. Clearing used delete, which gave every
such job a hidden class (Structure) of its own; persisting the job then built
a property-name cache of about 47 strings for it. That cost
about 1 KB per retained job (DLQ entries are kept up to 10,000 per queue by
default, completed jobs up to 50,000). Clearing now assigns undefined, so a
retried job keeps the shared hidden class. Job state, persistence, JSON,
MessagePack and wire output are identical, and reading the state still returns
null once cleared. The one visible difference: console.log, Bun.inspect
and test snapshots of an internal Job object (for example from
QueueManager.getJob()) that was retried and later completed or moved to the
DLQ now show the
cleared property as [Symbol(bunqueue.dlqRetryState)]: undefined, and
Object.getOwnPropertySymbols lists it.
| Metric (same host, same workload) | 2.9.11 | 2.9.12 | Change |
|---|---|---|---|
| Heap per DLQ entry of a retried job (isolated benchmark) | 1,768 B | 698 B | -60% |
Hidden classes (Structure) created per DLQ entry | 1.00 | 0.01 | -99% |
Structure objects created from 60 s to the end of a run | +10,478 | none | flat |
| Property-name enumerators alive at the end of a run | 16,806 | 0 | -100% |
| Strings alive at the end of a run | 1,318,866 | 551,813 | -58% |
| Heap objects alive at the end of a run | 2,748,629 | 1,972,509 | -28% |
| Peak RSS over the run | 865 MB | 807 MB | -7% |
| Jobs processed/s at saturation, 2 load processes | 2,425 | 2,489 | noise |
The run is 180 s of a realistic mixed workload on the broker in its own
process: 16 TCP queues with workers returning results, 5% of jobs failing twice
and reaching the DLQ, 10% reporting progress, priorities, delays and duplicate
custom IDs, 16 closed-loop HTTP clients, dashboard polling and 2 SSE
subscribers. Native macOS arm64 (18 cores, 64 GB), Bun 1.4.2, SQLite file
persistence, measured 2026-10-08 against c43442c9. Heap figures are one run
each; the isolated per-entry figures were repeated twice; the saturation
figures are the mean of two 90 s runs, and their difference is within
run-to-run variation. These are dated host engineering measurements, not
published benchmark results.
Testing
Section titled “Testing”- Three regression suites pin the 2.9.11 write path on failure paths, as a
guard for any future write-path optimization.
test/repro-write-path-faults.test.tsruns a 36-scenario fault-injection matrix (result and state writes failing always, once or on a given call; single and batch ACKs on jobs already on disk; unserializable results) and compares error, persisted state, result rows, job index, in-memory completion sets, disk-full flag and log lines with output recorded from 2.9.11 (test/fixtures/write-path-faults-2.9.11.jsonl). It runs in a child process with the shard count pinned (test/fixtures/pin-shard-count.ts), because a batch ACK extracts its jobs in shard order and the shard count follows the host’s CPU count.test/repro-write-path-equivalence.test.tscovers full-disk failures for still-buffered jobs, unserializable results, an ACK after the storage closed, telemetry around a rolled-back obliteration, payload mutation after an event, the backup boundary, and a trigger-based invariant that a completed row never exists without its result row.test/repro-write-path-contention.test.tsholds the database lock from another process and checks that a result-bearing ACK fails once with its jobs left active, and that telemetry reaches disk inside a sequentialawaitloop. All three pass unchanged on 2.9.11 and on 2.9.12.
Investigated, not shipped
Section titled “Investigated, not shipped”Profiling the same workload showed the broker’s main thread spending most of its time in synchronous SQLite writes, several commits per processed job. Grouping each ACK’s result and state writes into one transaction, and writing per-event telemetry once per event-loop turn, raised throughput substantially in that profile and reproduced 2.9.11 under injected faults and lock contention. On a physically full filesystem, though, changing commit boundaries alone changes when the WAL grows and checkpoints, and therefore which write fails, including an ACK’s outcome. Neither is part of this release, which changes no behavior; the regression suites above pin the behavior any future version of them must keep.
Documentation
Section titled “Documentation”- The docs header blurs the page behind it in Chrome and Firefox again. The
stylesheets declared
backdrop-filterbefore-webkit-backdrop-filter, and Lightning CSS, which minifies the site’s CSS, kept only the prefixed form, so only Safari blurred and other browsers showed text through the translucent header. The prefixed declaration now comes first and both ship. - The homepage “New” tags no longer wrap to “Ne/w” on phones. The tag stays on one line and the announcement text wraps beside it.
- A contact address, founder@bunqueue.dev, is in the site footer and in the publisher of the site-wide SoftwareApplication and per-page TechArticle structured data.
[2.9.11] - 2026-10-05
Section titled “[2.9.11] - 2026-10-05”Bun and Node.js run a timer whose delay is NaN, negative, infinite or above
2^31 - 1 ms (about 24.8 days) after about 1 ms, and an interval with such a
period, or with 0, about 870 times a second. Durations and counts reached
timers, leases and storage from env vars, the config file, CLI flags, client
options, job data and protocol commands without checks, so a typo or an
unusual value could become a hot loop, a spurious timeout, a stuck job or, in
two cases, lost data. Every timer is now armed through shared helpers
(src/shared/timers.ts) that honour any duration and never spin, and every
value is checked where it enters under one rule:
- a value 2.9.10 handled as you meant keeps working, with the same result;
- a value 2.9.10 silently ignored or replaced with a default keeps that effect, and now logs a warning naming the setting;
- only a value 2.9.10 misread, or could not run without a hot loop, a ~1 ms timer, a stuck job or lost data, is refused: at startup with one line naming the setting, or with an error naming the class and option.
Upgrading from 2.9.10 changes nothing for a deployment that works today, except the cases listed under Upgrade notes below.
Security
Section titled “Security”- A string
auth.tokensin the config file made every character a token.auth: { tokens: 'secret' }becamenew Set('secret'), sos,e,c,randteach authenticated. A string is now refused at startup. - Clients with different auth tokens could share one connection. Shared TCP pools were keyed by a 16-bit token fingerprint (in practice under 2,000 distinct values), so within one process a caller with a wrong or revoked token could reuse another caller’s authenticated connection. Pools and shared clients are now keyed by every connection option and the full 64-bit token hash.
BUNQUEUE_CLOUD_REMOTE_COMMANDS=0left remote commands on, andMETRICS_AUTH=1left/prometheusunauthenticated. Only the literal wordstrue/falsewere understood. Boolean env vars now accept1/0,true/false,yes/noandon/offin any case. Any other word keeps the value 2.9.10 gave it and logs a warning.- An empty auth token authenticated every unauthenticated HTTP request.
auth: { tokens: [process.env.API_TOKEN ?? ''] }with the variable unset type-checks, and a request without anAuthorizationheader matched the empty token; over TCP,Authwith an empty or whitespace token succeeded.AUTH_TOKENS=,and--auth-tokens ,silently turned auth off, and--auth-tokens ,overrodeAUTH_TOKENS. A blank token in the config file,AUTH_TOKENSor--auth-tokensnow stops startup with an error naming it (auth.tokens[0] must be a non-empty string (got undefined)), and the HTTP, TCP and WebSocket checks never accept a blank token. Configured and presented tokens are both compared trimmed, so a token read from a secret file with a trailing newline keeps working on both sides.
Data loss
- An invalid S3 backup retention deleted every backup. A
NaNretention from the config file passed validation and the prune deleted all backups, the one just uploaded included. A backup whose settings are invalid now logsS3 backup configuration invalid, naming each setting, and never runs or prunes; the server keeps running, as it did. BUNQUEUE_COMPLETED_RETENTION_MS=1e12deleted completed jobs at once.parseIntread it as 1 ms. A numeric env var or flag thatparseIntmisread (1e12,30s,6789abc) now stops startup with an error naming it. Every form it read as meant keeps working:+6789, a decimal fraction (truncated, asparseIntdid:1500.5is 1500), the setting’s own unit (5000ms,512mb) and a trailing# comment.
Jobs stuck, lost or out of order
- A job
timeoutof 0,NaNor above 24.8 days failed every job. The Worker aborted the processor after about 1 ms and dropped the outcome, while the broker treated the job as having no timeout. The job satactiveuntil stall or lease recovery redelivered it, ran its processor once per attempt and ended in the DLQ; on PostgreSQL every run lost its lease at once. 0 is a valid value over TCP. The broker, the Worker and PostgreSQL now share one rule (src/domain/job/timeoutRule.ts): 0 andNaNmean no timeout, long timeouts are honoured, and fractional timeouts, which the broker never enforced, are rounded up. - A
NaNdelay, backoff or timestamp broke the job. It stayed delayed forever or was silently dropped by the write buffer (NOT NULL), a grouped job with aNaNdelay froze the process, and every long-poll pull on the queue re-armed a timer about 870 times a second. These values are refused where they enter, and the engine treats a non-finite run time as due. - A numeric string option broke the job.
delay: '1000'was appended to a timestamp as text and scheduled the job hundreds of thousands of years ahead, andtimeout: '50'left the jobactive. Plain decimal strings are now read as numbers in every mode, soattempts: '3',priority: '5'andbackoff: '200'keep working and the broken cases do what they say. - A
backoffobject withoutdelaylost the job. In embedded mode the write buffer dropped it (NOT NULL constraint failed: jobs.backoff) and it stayedwaitingafter its first failure; flows threw the SQL error and TCP returnedInternal server error. It now uses the default 1000 ms base, in every mode and in job-scheduler templates. - PostgreSQL: one stored fractional
timeoutorstallTimeoutblocked the whole queue. Every claim of the queue head failed withinvalid input syntax for type bigint. Leases are now always whole milliseconds within the valid date range. - PostgreSQL: a priority outside the 32-bit range failed the insert. The column now holds the clamped value; the job keeps its exact priority.
- SandboxedWorker: jobs were sent to crashed threads. A thread that exited
kept receiving jobs once restarts were off or used up, and with
timeout: 0those jobs hung. A crash now fails the running job at once (retried per its attempts), a thread pastmaxRestartsis retired, and when none is left the worker stops with anerrorwhose context isexhausted. - SandboxedWorker: a restarted thread lost jobs while its processor
loaded. The thread installed its message handler only after importing the
processor, and the pool dispatched to it at once, so a job sent during a
slow import was dropped and stayed
active(or timed out and used up a restart). A thread now receives jobs only after its processor has loaded, a processor that fails to load counts as a crash, andstop()during a restart terminates the loading thread. - PostgreSQL: a wait on a job removed on completion reported “not found”.
Without
QueueEvents, a wait whose first state read ran after the removal missed the result the broker still held. It now asks the broker before settling. - A non-boolean
lifo(for example1) reversed the order of jobs. It is now stored as a boolean, on add and onchangePriority. - Over TCP,
job.clearLogs(n)cleared every log entry. The client droppedn; it now keeps the lastnentries, as embedded mode did.
Hot loops, floods and crashes
- Every timer that takes a configurable or computed delay honours long
durations and never spins. This covers:
- the Worker heartbeats, pull loop,
drainDelay, rate-limit waits anddelay(); - the TCP client ping, reconnect backoff, connect timeout and per-command timeout;
- the SandboxedWorker job timeout, idle watch, heartbeat and pull wait;
- Simple Mode priority aging, batch timeout,
cancel()grace period, circuit-breaker reset and retry backoff (which overflowed from attempt 22 on); - the server stats log, worker cleanup, rate-limiter cleanup, S3 backup interval, TCP idle timeout and internal lock timeout;
QueueManagerbackground intervals andwaitForJobCompletion;- PostgreSQL polling, lease, recovery and retry timers.
- the Worker heartbeats, pull loop,
- An invalid
LOCK_TIMEOUT_MSfailed every contended operation. Withabcor a value above the timer limit, every contended internal lock threwLockTimeoutErrorafter about 4 ms, embedded mode included. A value above the timer limit is now honoured, and one that is not a number stops startup with an error naming it. - Cron: an entry with a
NaNnext run spun the scheduler, savingNaNevery millisecond. It is now rescheduled from now and reported as missed, and other crons keep firing. WorkflowwaitForand sub-workflow steps with an invalid start time now fail cleanly. - PostgreSQL: a failed post-commit maintenance step retried back to back (89 attempts in 100 ms), skipping its retry delay. Retries now wait, capped at 1 s, and a long poll interval no longer delays them.
- TCP mode: a synchronous Queue method could crash the process when the
broker was unreachable.
pause(),resume(),drain(),obliterate(),remove(),job.discard(),setDlqConfig()(also run by thedlqconstructor option),retryDlq(),purgeDlq(),retryCompleted(),setStallConfig()and the rate-limit and concurrency setters sent their command without a rejection handler, so a failed send became an unhandled rejection and Bun ended the process. A failure is now logged as one line naming the command and the queue; Simple Mode emits it on itserrorevent while a listener is attached, and a failed triggeraddis reported the same way. The...Asyncvariants still reject to their caller. - Worker: a refused pull looked like an empty queue. A Worker or
SandboxedWorker refused for good (a bad token, an invalid queue name, a
rejected option) idled silently and emitted
drained. It now backs off (100 ms to 30 s) and resumes once the cause is fixed. The refusal goes to theerrorevent (context: 'pull') when a listener is attached, and otherwise to oneconsole.errorline a minute naming the queue and the reason; it never ends the process. Transient refusals (rate limit, lock timeout, internal error) are re-polled on the empty-queue cadence and not reported, as before (drainDelay, 10 ms with a long poll,pollIntervalfor SandboxedWorker), but no longer emitdrained, since jobs may still be waiting. Anerrorlistener that throws no longer causes an unhandled rejection, andgetNextJob()still resolvesundefinedon a refusal. - SandboxedWorker: one pull error stopped pulling for good, and a failed
start()left a worker that reported running but pulled nothing. Pull errors now back off and retry, and a failedstart()rejects and leaves nothing running.stop()is idempotent and always wins overautoStart, andautoStartnow works in TCP mode when the worker is the pool’s only user. - TCP client connection races.
close()duringconnect()now wins and closes the socket. A socket that hits the connect timeout is closed (they stayed open, and a late close could drop the newer connection).close()inside a'reconnecting'listener no longer reconnects. - Simple Mode: aging ticks no longer overlap, a failed aging query no
longer crashes the process with an unhandled rejection, a
NaNaging boost is refused, and a constructor that throws no longer leaves its Queue and Worker running. - MCP:
BUNQUEUE_POOL_SIZE=Infinitylooped until the process ran out of memory.Infinityand values above 65535 now stop the MCP server with an error naming the variable; other unreadable values use 2 with a warning, and1e3is still 1000.
Changed
Section titled “Changed”- Values that 2.9.10 silently ignored or replaced now log a warning and
keep the same effect. Among them:
-1or a non-number TCP idle timeout, TCP write-queue cap or monitoring threshold (disabled), and an invalid completed-job retention (off);- an invalid
METRICS_MAX_QUEUES,*_MAX_COMPLETED_JOBS, PostgreSQL count or session timeout, andS3_BACKUP_INTERVAL/S3_BACKUP_RETENTIONof0or a non-number (the default); RATE_LIMIT_MAX_REQUESTS=abc(no rate limit) andWEBHOOK_RETRY_DELAY_MSofabcor-1(immediate retries);- settings of a feature that is off: PostgreSQL settings on SQLite or in
memory, Cloud numbers without Cloud, S3 settings with backups off, and
BUNQUEUE_CLOUD_INTERVAL_MS, which is unused (the upload cadence adapts to the snapshot size, 5 to 30 s); - an S3 backup that cannot run (missing bucket or credentials, an interval
under a minute, an invalid retention): still logged at error level as
S3 backup configuration invalid, now naming each setting; - an unknown log level or format word;
- a string for a boolean key in the config file, which is still read by
JavaScript truthiness (
backup.enabled: 'false'keeps backups on): usetrueorfalse; - metrics auth on with no auth token configured (
/prometheusanswers 503, as before); timeouts.worker,timeouts.lockandwebhooks.*in the config file, which stay ignored as documented: setWORKER_TIMEOUT_MS,LOCK_TIMEOUT_MSandWEBHOOK_*instead;- in the CLI client, an env port that is not a port (Kubernetes injects
BUNQUEUE_TCP_PORT=tcp://10.96.0.12:6789for a Service namedbunqueue-tcp), which uses 6789; - in the config file, an
undefined,nullor emptycors.originsentry (dropped), and keys the file does not know.
- The config file accepts what env vars accept. Numeric strings
(
tcpPort: process.env.PORT),nullas “unset” for a key or a whole section, and a comma-separatedcors.originsstring.NaN,''and out-of-range ports stop startup with an error naming the key. bunqueue start:--tcp-port 0and--http-port 0bind an OS-assigned port, an empty flag value (--tcp-port=,--host=,--config=) means the flag was not given, andbunqueue backupreads the same config file as the server.- Logs:
LOG_LEVELandLOG_FORMATare case-insensitive,LOG_LEVELaccepts the aliaseswarning(warn),traceandverbose(debug),fatalandcritical(error),NO_COLORaccepts any non-empty value,FORCE_COLORis supported, and output piped to a file ordocker logshas no color codes. - Client options are checked at construction (
Queue,Worker,SandboxedWorker,Bunqueue,ConnectionOptions,TcpConnectionPool,QueueManager). A numeric string is its number, a fraction rounds as 2.9.10’s comparisons did (concurrency: 2.5runs 3), a negative orNaNinterval that 2.9.10 guarded with> 0means disabled, and an option the active mode does not use is not checked, and noautoBatchvalue throws (an invalidmaxDelayMsflushes at once, as 2.9.10’s timer did). Only values 2.9.10 could not run throw, with an error naming the class and option, for exampleWorker: heartbeatInterval must be a finite number of milliseconds >= 1 (got 0.5). - One job-options validator for every entry point (embedded
addandaddBulk, TCP PUSH and PUSHB, flows, job-scheduler and cron templates, MCP, Cloud). It refuses only what no job can run with: a non-number,NaNor infinite value, a negativetimeoutorttl, and atimestampoutside the date range. Errors name the option you passed (attempts,deduplication.ttl), andaddBulkadmits nothing when one job is invalid, reportingjobs[i]: ….attemptsof 0 or less runs the job once in every mode (TCP used to refuse it), andattempts: Infinityretries up to 2,147,483,647 times.- A negative
delaykeeps its past run time in every mode, as in 2.9.10 embedded mode: the job is ready at once and runs ahead of later ready jobs. TCP, HTTP and MCP used to refuse it.changeDelaywith a negative delay does the same (PostgreSQL makes the job ready now, as in 2.9.10), andmoveToDelayed(timestamp)with a past timestamp still means “now”. - Durations above about 136,900 years are clamped instead of overflowing.
job.updateProgress()never fails the job. A numeric string,trueornullis stored as 50, 1 or 0, as in 2.9.10; any other text is stored as 0 with the text as the message (2.9.10 storedNaN). Theprogressevent still carries the value you passed.changePrioritywithout a priority uses 0 (BullMQ’s default) instead of failing in embedded mode on aNOT NULLconstraint.- TCP clients now read the replies to
changePriority,clearLogsandextendLock, which used to be ignored, so a refusal is reported. - Connections: callers whose timeouts or ping settings differ no longer share a pool.
RATE_LIMIT_CLEANUP_MSof 0, negative or a non-number now sweeps the rate limiter every 60 s, with a warning. With 0 or a non-number 2.9.10 never swept it, so entries for past clients accumulated; with a negative value it swept about every millisecond.- PostgreSQL: expired leases are scanned at least every 15 s, and a job
timeoutof 0 means no timeout. - Invalid persisted cron templates log one warning at load and keep their stored values, so they never block startup.
Upgrade notes
Section titled “Upgrade notes”Check these before upgrading from 2.9.10; everything else behaves as before.
- Server settings that now stop startup, because 2.9.10 misread them or
could not run with them:
- a numeric env var or flag
parseIntmisread:30s,5m,1e3,6789abc(write30000; settings 2.9.10 read withNumber(), such asBUNQUEUE_POOL_SIZEand--max-completed-jobs, still accept1e3); NaN, empty or out-of-range ports in the config file;STATS_INTERVAL_MS=0,SHUTDOWN_TIMEOUT_MSof-1or a non-number,LOCK_TIMEOUT_MS,WORKER_TIMEOUT_MSorWORKER_CLEANUP_INTERVAL_MSof 0 (andWORKER_TIMEOUT_MS=abc),WEBHOOK_MAX_RETRIES=0,RATE_LIMIT_MAX_REQUESTSof 0 or less;- a blank auth token, a string
auth.tokens,--auth-tokens "",--data-path "", an empty--tls-cert=or--tls-key=; - a config file that exports a function or an array;
- an invalid Cloud number when Cloud is configured;
- in embedded mode, the first
QueueorWorkerthrows for the sameLOCK_TIMEOUT_MS,WORKER_*andWEBHOOK_*values.
- a numeric env var or flag
- Switches that now take effect. 2.9.10 understood only
trueforMETRICS_AUTHandS3_VIRTUAL_HOSTED_STYLE, and onlytrueor1forS3_BACKUP_ENABLED;1,yes,onorTRUEnow mean on (withMETRICS_AUTHon, a Prometheus scraper needs a token). TheBUNQUEUE_CLOUD_REMOTE_COMMANDS,_INCLUDE_JOB_DATA,_USE_WEBSOCKETand_USE_HTTPswitches set to0,no,offorFALSEused to mean on and now mean off. - Logging:
LOG_FORMAT=JSONorLOG_LEVEL=DEBUGin capitals now apply, andLOG_LEVEL=warning,trace,verbose,fatalorcriticalnow set the matching level (2.9.10 ignored all of them and logged atinfo). --tcp-port 0binds an OS-assigned port (2.9.10 fell back to 6789), andbunqueue backupnow uses the bucket and database path of the config file in the working directory, like the server.- Client values that now throw, because 2.9.10 never processed a job,
looped, hung or lost the lease with them: Worker
concurrencyof 0 or less orNaN(oftenNumber(process.env.X)with the variable unset),batchSize: 0, alockDurationof 0 or less,NaNor infinite while locks are on,drainDelay: 0without a long poll, a heartbeat interval below 1 ms or infinite; connectionhost: '', aconnectTimeout,commandTimeout,reconnectDelayormaxReconnectDelayof 0,maxPingFailuresof 0 or less,poolSizeabove 65535; Simple ModepriorityAging.intervalbelow 1 ms. - Client values whose meaning changed:
autoBatch.enabled: 'false'or0now turns batching off, andworker.rateLimit('1000')now applies. - A permanent pull refusal is now reported: a Worker with an
errorlistener receives anerrorevent (context: 'pull'), and one without a listener logs a line at most once a minute. 2.9.10 stayed silent. - Connections: callers whose timeouts or ping settings differ open separate pools, so a process can open more connections.
bunqueue-client0.2.3 (TypeScript SDK; details insdk/typescript/CHANGELOG.md) ships these client fixes, the security fix for shared pools and the 2.9.10 wait fix. Its legacy entry (bunqueue-client/legacy) arms every timer through the same helpers and keeps every 0.2.2 option result that was not broken: numeric strings, a negativemaxInFlight(unbounded), fractional pool sizes, Simple ModemaxAttempts: 0,threshold: 0andbatch.size: 0, unknown retry strategies (a fixed delay). Only values that caused a hot loop, a hang, a crash or a ~1 ms timer now throw.- Python, Go, Rust, PHP and Elixir SDKs follow the same rule: each keeps
every value its previous release handled as meant. The fixes are listed
under
Unreleasedin each SDK’s changelog.- A worker with poll timeout 0 re-polled an empty queue about 10,000 times a second until the broker’s rate limiter cut it off. Every SDK now pauses after an empty pull: 50 ms, or 10 ms after a long poll.
- The Python Worker stopped on a rate limit, a lock timeout or an internal
error. These are now retried on its existing 0.5 to 5 s schedule; a
permanent refusal (a bad token) still ends
run()with the error unless anerrorlistener is attached. Its reconnect backoff no longer overflows after about 85 minutes of outage. - Elixir no longer turns a float or
nillock_ttlinto a 1 ms lease (nilmeans 30 s, a float rounds up), andstop/1no longer hangs after a linked crash. - A tiny Go heartbeat interval panicked, and PHP turned a
NaNcommand timeout into a busy-spinning read.
Documentation
Section titled “Documentation”- New internal references:
docs/features/shared-timers.md(the timer limit, the helpers, the validation policy) anddocs/features/job-options-validation.md. docs/features/configuration.mdhas an “Upgrade compatibility with 2.9.10” table.- The env var, configuration, worker, sandboxed worker, Simple Mode, TCP protocol, HTTP API, PostgreSQL and backup pages document the accepted range of every setting and option, and what 0, negative and very large values mean.
[2.9.10] - 2026-10-03
Section titled “[2.9.10] - 2026-10-03”- A wait TTL longer than about 24.8 days now lasts the whole TTL.
Queue.waitJobUntilFinished()andjob.waitUntilFinished()with a TTL above 2^31 - 1 ms rejected within milliseconds withtimed out after <ttl>mswhen the job was still pending, and printed aTimeoutOverflowWarning: Bun and Node.js fire a longer timer after 1 ms. The deadline is now armed in chunks of at most 24 days, each measured against the clock, so the wait runs until the TTL really elapses, in embedded and TCP mode, with or withoutQueueEvents.bunqueue-clientgets this fix with its next release; 0.2.2 still has the limit.
- Docker tags in the docs follow the release version.
test/docs-docker-tags-version.test.tsfails when the README or a guide pins an image tag other than the version inpackage.json, so a release can no longer ship docs that point at the previous images. test/repro-wait-long-ttl.test.tsreproduces the TTL bug in both modes, andtest/job-wait-long-deadline.test.tschecks the chunked deadline: it fires once and never early, clearing it cancels it, and a settled 30-day wait lets the process exit.
[2.9.9] - 2026-10-03
Section titled “[2.9.9] - 2026-10-03”- Waiting for a job settles on its final outcome, for every Job and in both
modes.
Queue.waitJobUntilFinished()andjob.waitUntilFinished()rejected with the first attempt’s error as soon as a job with retries left failed once, even when a later attempt completed. A wait now resolves with the result once the job completes and rejects with the last attempt’s error once no retry is left, including a job the stall detector moves to the DLQ, and rejects withJob <id> not foundonce the job no longer exists (removed, drained, obliterated, or removed on completion before the wait saw it).QueueEventsstill firesfailedfor every attempt and now marks itterminal(falsewhile a retry is pending). - TCP waits without
QueueEventsreport failures and survive long TTLs. A job that already failed settles at once; one that fails later is reported by the next state read (about 1 s after the start, then every 2 to 30 s, ±25%, while at most about 600 waits share a connection pool; less often beyond that) instead of at the TTL.WaitJobholds run under their own command timeout, socommandTimeoutno longer cuts a wait short, and TTLs above 600000 ms are honoured (up to about 24.8 days, the runtime’s timer limit). Holds are capped at 40 per connection (160 on a default pool of 4), so other commands keep broker slots: with a TTL at or abovecommandTimeoutand jobs still pending, 120 waits on one connection used to fail withConnection lostafter about 30 s. Beyond those slots a wait queues for one and can see a completion seconds late (1,000 concurrent waits on 1 s jobs: p90 2 s, where every wait used to see it at once), so useQueueEventsfor high-concurrency request/response.TcpConnectionPoolgains per-command timeouts (send(command, { timeout })) and per-connection long-poll leases (reserveLongPoll(perConnection)). - Waits no longer hang, and they no longer starve other traffic. A wait
with
QueueEventsre-reads the job onstalled,removedand after a reconnect, carries on when theQueueEventscloses, and re-reads as a jittered safety net. Background reads share a budget of 20 per second per connection pool, and rate-limit refusals, command timeouts and lost connections are retried instead of rejecting the wait. After a broker outage a wait settles once the client has reconnected, which can lag by the reconnect backoff (up to 30 s) plus the next state read. The TTL is a timer of its own. Embedded waits share one manager subscription, never start a new engine aftershutdownManager(), and reject withwaitUntilFinished: the embedded engine was shut downwhen it stops. A TTL of0means no timeout with or withoutQueueEvents; an embeddedqueue.add()job acceptsnullinstead ofQueueEvents; a refused state read rejects with the broker’s error; a job without a connection rejects instead of resolvingundefined; and TCPJobobjects now listen to theQueueEventsthey are given. One implementation (src/client/jobWait.ts,src/client/job-wait/) replaces six copies that had drifted apart. The fix ships inbunqueue-client0.2.2.
Documentation
Section titled “Documentation”- New diagrams in the MCP guide. “Where the queue lives” now reads left to
right: your AI client, the stdio or Streamable HTTP link,
bunqueue-mcpwith its optional features, and a fork to the embedded and TCP modes with what each keeps. The HTTP handler diagram shows a job in four numbered steps, from added to completed or failed, in the job-state colors the homepage uses, and states which methods send a body. Both follow dark and light mode and stack vertically on phones. The guide now says that GET and DELETE handlers send no body. - The queue simulator looks like itself again. The refreshed heading,
link, inline-code and table styles had reached inside the simulator,
turning its lane and panel titles into large page headings. Those theme
rules now skip widgets and diagrams marked
.not-content, as Starlight’s own styles do, and a test keeps it that way. - Every docs graphic in one visual language. The simulator, the Web Dashboard architecture diagram and the three explainers on the Examples page now share the MCP diagrams’ style: hairline panels on the docs color tokens, sentence-case labels instead of uppercase tags, monospace only for ids, ports and code, brand pink only for the subject or the active choice, and one job-state palette for every state they show (waiting gray, delayed indigo, retry amber, active pink, completed teal, failed rose). Each one follows its own width, so it no longer cramps next to the sidebar and stacks cleanly on phones without scrolling the page sideways.
- Simulator. Lanes, chips, counters, the event log and the shard map use that palette; chips show the id, job name and queue on separate lines; sliders announce their units; speed and queue choices expose their pressed state and the last scenario run is marked current; counts are spelled out next to every color; and reduced motion turns off its animations. Its behavior is unchanged.
- Web Dashboard diagram. It now draws two separate links to the server,
HTTP :6790from the UI server’s/api/*proxy andTCP :6789from the control agent’s SDK bridges, shows the UI server bridging/agent/*to the agent and the optional Copilot calling the AI provider directly. The guide also corrects how long tokens last: they are kept in memory only, so reloading the page clears them. - Examples page. The learning path is a compact numbered rail of clickable rows. The job lifecycle explorer shows every step with the state bunqueue reports, marks the attempts that throw with ”✕ fails”, and explains the current step in a card that screen readers announce. The topology explorer draws each layer as a zone joined by labelled links, with a one-line summary and the durability owner. All three lay themselves out by their own width, so they stack before a label or node name would have to wrap mid-word, and both explorers keep the same height whichever option is chosen and work fully from the keyboard.
- AI agents on the homepage. A new section below the Academy presents the MCP server’s opt-in features (toolsets, confirmation of destructive calls, decision models) with the setup command, an example confirmation and the supported decision models (Jev, Clef, Clef-flash, Kev 9B, Laya, DiffusionGemma Jev); a second “New” announcement in the hero links to it.
- MCP guide rewritten for first-time readers. It now starts with a three-step setup with tabs per client, then explains where the queue lives, HTTP handlers and the optional features, with the HTTP server, workflow approvals, the parameter details and the environment variables after them. Every anchor that other pages link to is kept.
- A first job in about a minute, the same everywhere. The Quick Start now
opens with three numbered steps for each path (start the server or install
bunqueue, save the file and run it, see the expected output) in Node.js, Bun,
Deno, Python, PHP, Go, Rust, Elixir and embedded Bun, followed by a table of
where to go next; the video follows the first job. The homepage quickstart,
the Quick Start and the README now show the same files from one source,
checked by
test/docs-homepage-snippets.test.ts. - Simpler navigation. The sidebar goes from 17 groups to 11, starting with
the Quick Start: cron, the dead letter queue and flows share one group, a new
AI Agents group holds the MCP server and the agent integrations, and blog
posts are listed on the blog page. No URL changed. The header keeps Academy,
AI Agents, Dashboard and Blog; “vs BullMQ”, the simulator and
llms.txtmoved to the sidebar and the footer. - A refreshed look. Page titles use the homepage’s condensed typeface, code blocks use cleaner GitHub-style colors without the fake terminal window, inline code is neutral instead of pink, and notes, tips and warnings use one thin border in the colors of job states. The sidebar and the table of contents mark the current page with a thin pink rail.
- Easier reading. Guide paragraphs hold about 75 characters per line instead of about 100. On phones, tables become one card per row instead of scrolling sideways, and the page title starts higher.
- Leaner site stylesheet.
custom.cssloses about 1,500 lines of rules for components no page renders any more: earlier homepage designs with their syntax colors, hero grid and benchmark card, the first queue simulator’s lanes, the step list, and the terminal, language, trust and vs-BullMQ cards. The main site stylesheet shrinks from 154 KB to 130 KB (26.4 KB to 22.8 KB gzipped) and no page looks any different. - TCP protocol reference in nine pages.
/api/tcp/keeps the wire format, connection, negotiation, pipelining, authentication, response format and limits, plus a command summary that links every command to its section. The command reference moved, unchanged, to eight pages under/api/tcp/: jobs, queries, control and limits, DLQ, cron, flows, monitoring, and workers and webhooks. Command and family anchors now live on those pages. - A professional README for
bunqueue-client. The npm page of the TypeScript client now opens with the standard SDK header, then the same tested quick start as the docs (start the server, savejobs.mjs, run it), waiting for a result withQueueEvents, a runtime support table (Node.js 20+, Bun 1.4+, Deno 2+, Cloudflare Workers), and notes on ESM-only loading, CommonJS bundling, embedded mode and Deno. The migration guide from 0.1.x keeps its anchor.test/docs-homepage-snippets.test.tsnow also fails if this quick start drifts from the docs. npm shows it withbunqueue-client0.2.2.
[2.9.8] - 2026-10-03
Section titled “[2.9.8] - 2026-10-03”MCP server
Section titled “MCP server”bunqueue-mcp now has 75 tools (73 before) plus 3 opt-in workflow tools. A
client that sets none of the new variables still runs over stdio and sees the
same tools, two new ones and new optional parameters; the output and input
changes are listed under Breaking changes below.
- Streamable HTTP transport (
BUNQUEUE_MCP_TRANSPORT=http). One long-running server serves several MCP clients, one session each, sharing a single queue connection and one set of HTTP handlers. It listens onhttp://127.0.0.1:6791/mcpby default (BUNQUEUE_MCP_HTTP_HOST,_PORT,_PATH) and accepts the bearer tokens inBUNQUEUE_MCP_HTTP_TOKEN, compared in constant time; on a non-loopback address it refuses to start without one.HostandOriginare checked against the bound address andBUNQUEUE_MCP_HTTP_ALLOWED_HOSTS/_ALLOWED_ORIGINSto block DNS rebinding. Sessions are capped (_MAX_SESSIONS, default 100) and closed after 30 idle minutes (_SESSION_TTL_MS), never while a tool call is running. There is no built-in TLS: put a reverse proxy in front. - Workflow approvals from the agent (
BUNQUEUE_MCP_WORKFLOW_DB).bunqueue_list_workflow_executions,bunqueue_get_workflow_executionandbunqueue_signal_workflowread the workflow Engine’s database and deliver human-in-the-loop signals through the same first-writer-wins transaction asengine.signal(); the application’s Engine runs the resumed step. Signals need the Engine connected over TCP with its owndataPath, the MCP server in TCP mode against the same broker, and the file on the same host. With an embedded Engine, or an embedded MCP server, the signal tool is not registered and stderr says why. The server never creates the database: a missing file, or one without the workflow tables, stops startup.BUNQUEUE_MCP_WORKFLOW_QUEUEnames a non-default Engine queue. - Job options.
bunqueue_add_joband eachbunqueue_add_jobs_bulkitem acceptbackoff(a number, or{ type, delay, maxDelay }),timeout,jobId,deduplication({ id, ttl, extend, replace }),removeOnComplete,removeOnFail,durable,lifo,tagsandstallTimeout, next topriority,delayandattempts(new on bulk items). The flow tools accept the sameoptsexceptdeduplication,tagsanddurable.bunqueue_get_joband job listings return the options a job carries. - Lock tokens.
bunqueue_pull_jobandbunqueue_pull_job_batchtakeownerandlockTtl; each job then comes back with atokenthatbunqueue_ack_job,bunqueue_fail_jobandbunqueue_job_heartbeataccept (tokenson the batch tools). A heartbeat with the token renews the lock, andbunqueue_fail_jobtakesunrecoverableto go straight to the DLQ. - DLQ failure reasons and statistics.
bunqueue_get_dlqreturns each entry’sreason, last error, recent attempt history, retry count and retry and expiry times, filters byreasonand pages withoffset. The newbunqueue_get_dlq_statsreturns the total, the entries per reason, the entries due for automatic retry, the expired entries and the oldest and newest entry. Thebunqueue_debug_queueandbunqueue_incident_responseprompts use both instead of a short job list. - Queue limits. The new
bunqueue_get_queue_limitsreads a queue’s rate limit, concurrency limit and active jobs, whether either limit is holding jobs back, and whether the queue is paused.bunqueue_set_rate_limittakesduration(the window in ms, default 1000), so limits per minute or per hour are possible. - Cron options.
bunqueue_add_crontakestimezone,jobName,priority,maxLimit,immediately,skipIfNoWorker,preventOverlap,skipMissedOnRestart,deduplicationand options for the jobs it adds (attempts,backoff,timeout,delay,stallTimeout,removeOnComplete,removeOnFail). Add, list and get returnjobName,priority,timezoneandmaxLimit; the policy flags, deduplication and job options are applied but not returned, because the broker’s cron reply does not carry them. - Load only the tools an agent needs (
BUNQUEUE_MCP_TOOLSETS). The 75 tools are grouped into 11 toolsets (12 with the workflow tools). A list such asqueues,dlqexposes only those groups;dynamicstarts the agent from a catalog and loads groups on demand (bunqueue_enable_toolsets, withbunqueue_call_toolfor clients that do not refresh their tool list). The full list costs about 16,000 tokens of context per request; the catalog with one toolset loaded costs 1,300 to 4,600 (about 1,800 for a typical toolset). On the 11 always-on toolsets, TypeSafe Jev routed 71 of 72 test requests to the right toolset, and 72 of 72 after the description involved in the miss was reworded; the requests, the script and the results are inscripts/mcp-eval/. - Confirm destructive operations (
BUNQUEUE_MCP_CONFIRM=destructive). Every tool gets MCP annotations (read-only, destructive, idempotent, open-world). Nine tools that delete data for good or re-run work in bulk (obliterate_queue,drain_queue,clean_queue,purge_dlq,cancel_job,clear_job_logs,delete_cron,remove_webhook, bulkretry_completed), plussignal_workflowwhen the workflow tools are enabled, first show their concrete impact and run only after the user confirms through the client, or, in clients that cannot ask, after the agent repeats the exact target inconfirm. Previews count paused jobs, andpurge_dlqstates the exact number of entries it deletes. - Decision models (
BUNQUEUE_MCP_DECISION_*). One client for the SystemOne request format serves TypeSafe Jev, Cloudflare Clef and Clef-flash on Workers AI, and any compatible self-hosted endpoint (Clef, Clef-flash, Kev 9B, Laya, DiffusionGemma Jev). It powersbunqueue_find_toolsand an extra check on destructive calls when the client cannot ask the user: the model must judge that the user’s own words ask for exactly that operation. The model can only block a call, and any model error refuses it.
MCP server fixes
Section titled “MCP server fixes”Most of these affected TCP mode (BUNQUEUE_MODE=tcp), where the MCP server
talks to a remote broker. Regressions: test/repro-mcp-tcp-wire.test.ts,
test/repro-mcp-tool-semantics.test.ts,
test/repro-mcp-tcp-handler-longpoll.test.ts,
test/mcp-bin-tcp-handler.test.ts, test/mcp-flow-options.test.ts.
- Broker errors were reported as success in TCP mode. A command the
broker rejected (an invalid job, an unknown id, a wrong lock token, an
authentication error) was treated as successful:
bunqueue_add_jobreturned a job id that did not exist, and acknowledgements, failures and the true/false tools reported success. The broker’s message is now returned as an error; “not found” and “wrong state” still returnsuccess: false. bunqueue_retry_dlqwith ajobIdretried the whole DLQ in TCP mode. The id was sent under a field the broker ignores. The drain, purge and retry counts and the batch heartbeat count read the wrong reply field and showed 0.bunqueue_wait_for_jobreported a timeout as a completion in TCP mode, and in embedded mode waited out the whole timeout for a job that had already completed.- Webhook and worker ids were
"0"in TCP mode.bunqueue_list_webhookswas always empty,bunqueue_remove_webhooksent the wrong field, and registered workers showed no counters. - Job logs, memory statistics and Prometheus metrics were empty in TCP
mode,
bunqueue_get_statsincluded protocol fields, andbunqueue_clear_job_logsignoredkeepLogs. bunqueue_change_delaysent the wrong command in TCP mode. It usedMoveToDelayed, which only accepts active jobs, so changing the delay of a delayed job failed.- A 30-second long poll failed with “Command timeout”.
bunqueue_pull_jobandbunqueue_pull_job_batchaccept atimeoutMsof up to 30 s, the same as the connection’s command timeout. The MCP connection now waits 45 s, so an empty queue returnsjob: null. - HTTP handlers never processed jobs in TCP mode. Their workers always
ran on an embedded queue inside the MCP process. They now connect to the
remote broker, and a TCP-mode MCP server never opens a local database, even
with
DATA_PATHset. A broker the handler cannot reach is reported on stderr instead of crashing the server. - TCP-mode flows could go to an in-process engine. With
BUNQUEUE_EMBEDDED=1in the environment, the flow tools committed flows to a local engine instead of the broker. bunqueue_clean_queuewithoutstatedeleted waiting jobs. The tool promised completed and failed jobs, but the broker read the missing state aswaiting. It now cleans completed and then failed jobs, withlimitcapping the total, and never touches waiting, delayed or active jobs.- Wrong job counts.
bunqueue_count_jobscounted only queued jobs and now counts every state. A paused queue’s ready jobs were counted twice in embedded mode (bunqueue_get_job_counts,bunqueue_get_queue_stats). Counts now includewaiting-childrenin both modes. - Missing job state and progress.
bunqueue_get_jobandbunqueue_get_job_by_custom_idnow return the job’sstate(pulled jobs areactive, DLQ jobsfailed).bunqueue_get_progresssaid “not found” for any job that was not active; it now returns the stored progress of any existing job. - Prioritized jobs could not be listed.
bunqueue_get_jobsrejected theprioritizedstate, which holds every waiting job with a priority; it now also acceptswaiting-childrenandpaused. - Input validation. Invalid input reached the broker or was silently
dropped: empty queue and job names, fractional priorities, attempts and
rate or concurrency limits, unknown flow options, and ids with broken
Unicode that TCP mode stored mangled. These are now rejected before
anything reaches the queue, with the same bounds the broker applies.
Regression:
test/repro-mcp-input-bounds.test.ts.
Breaking changes
Section titled “Breaking changes”bunqueue_get_dlqreturns entries instead of jobs. The output is{ queue, reason?, offset, count, hasMore, entries }, with each entry’sjobinside it; the top-leveljobsarray is gone.limitmust be an integer from 1 to 100 (default 20).- Stricter MCP inputs, in both modes.
bunqueue_set_rate_limitandbunqueue_set_concurrencyneed integers of at least 1.bunqueue_add_cronneeds a non-emptyname, an integerrepeatEveryof at least 1, an integerprioritywithin ±1,000,000, and atimezonethat is a known IANA zone and comes with aschedule. Flowoptsreject unknown keys, includingdeduplication,tagsanddurable, instead of dropping them.bunqueue_extend_lockcapsdurationat 24 hours, andlockTtlrequiresowner.bunqueue_move_to_delayedandbunqueue_change_delaycapdelayat one year like the add tools;bunqueue_clean_queuelimit,bunqueue_get_jobsstart/endandbunqueue_get_flowdepth/maxChildrenmust be integers (limit,depthandmaxChildrenat least 1);bunqueue_get_cronandbunqueue_delete_cronreject an empty name. A rejected call changes nothing. bunqueue_wait_for_jobfails with “Job not found” for an unknown id instead of waiting out the timeout, in both modes.
Workflow engine
Section titled “Workflow engine”- A signal racing a
waitFortimeout from another process could be reported as resuming a run that then failed. When a timed gate expired, the worker re-read the run, found no signal, and then wrotefailedwithout checking again. A signal recorded in between from another connection (a second application process, or the MCP server) was accepted withresumed: true, yet the run failed by timeout and was compensated. The expiry is now oneIMMEDIATEtransaction that fails the run only if it is still parked at that gate without the signal, so whichever side commits first wins: a late signal is rejected, and a signal that wins keeps the run going.signal:timeoutis now emitted only after the failure is saved, and never when the signal wins. Regressions:test/repro-workflow-signal-timeout-race.test.ts,test/workflow-signal-timeout-claim.test.ts.
Documentation
Section titled “Documentation”- Generated API reference pages are
noindex, followand out of the sitemap. 262 of the 383 sitemap URLs were near-identical TypeDoc pages, and Search Console reported most of the sitemap as not indexed, including hand-written guides such as the quickstart and installation pages. The generated pages stay browsable and linked from/reference/, and keep their specific titles, descriptions and self canonicals. Any existingrobots,googlebotorbingbotdirective on them is replaced by exactly onerobotstag, anddocs:validate-discoverynow fails if a current reference page is indexable. The sitemap goes from 383 to 121 URLs. - The home page quickstart runs as written in every language. Deno now
gets
--allow-readand unscoped--allow-sys, so the worker no longer stops at a permission prompt in a terminal. PHP and Go list their minimum toolchain versions. Elixir clones with--depth 1, says whichdeps/0to replace, and installs Hex beforemix deps.getso a fresh machine does not prompt. Both “See it work” steps say that the worker keeps waiting and that Ctrl+C stops it.
[2.9.7] - 2026-10-02
Section titled “[2.9.7] - 2026-10-02”SDK releases
Section titled “SDK releases”Every official SDK now has a release that speaks wire protocol v3, where job
names travel outside data. The packages published on 2026-07-20 (Python
0.1.5, PHP 0.1.1, Go v0.1.0, Rust 0.1.1) speak protocol v2: against a server
2.8.57 or later they lose the job name (job.name is empty), which breaks
name-based routing such as Python Simple Mode, and their flows are not atomic.
Upgrade every client together:
| SDK | Release | Notes |
|---|---|---|
TypeScript bunqueue-client | 0.2.1 | Ships the engine fixes below for embedded mode; BackoffOptions accepts maxDelay. |
Python bunqueue-client | 0.2.0 | Includes the never-published 0.1.6. |
PHP bunqueue/client | 0.2.0 | Breaking: Job::data() returns mixed. |
Go sdk/go | v0.2.0 | Breaking: Job.Data() returns any. |
Rust bunqueue-client | 0.2.0 | Breaking: unsupported flow options are rejected. |
Each SDK changelog lists the full set of changes. The Elixir SDK is not on Hex yet and is used from a checkout.
Behavior changes
Section titled “Behavior changes”PUSH,PUSHB,PUSHFand HTTP push validatebackoff.maxDelay. A value that is not a finite number between 0 and 86,400,000 ms is now rejected; it used to be accepted and silently dropped. Go, PHP, Python and Rust pass the backoff object through unchanged, so their callers see the error too.- A
nullbackoff is accepted.PUSHwithbackoff: nullused to fail with a raw engine error (null is not an object (evaluating 'input.delay')); it now uses the default backoff. - Elixir SDK: invalid
deduplication:values raiseArgumentError. A value without a non-empty stringid, with unknown or repeated fields, that is not a map or keyword list (structs included), or combined with a rawdedupoption used to be accepted and ignored. - A genuinely orphaned active job is retried instead of disappearing. When
stall detection is enabled for its queue, a job that sends no heartbeat,
progress update or lock renewal for longer than both 30 minutes and its stall
timeout (
stallTimeout, or the queue’sstallInterval), and holds no lease from its current delivery, is now recovered like a stalled job: retried with the attempt counted, or moved to the DLQ. It used to vanish from memory until a restart. Queues with stall detection disabled are left alone. Deliveries that keep sending heartbeats, or that hold an unexpired lease, never reach this path. DelayedErrordelays are capped and never zero. The delay is the base backoff (1 second when it is not positive) capped at a positivebackoff.maxDelay, or at 1 hour otherwise. AmaxDelayof 0 does not apply toDelayedError, so the postponement is never zero. A numeric backoff above 1 hour used to postpone the job for the full value. TCP workers now seejob.opts.backoffin its object form when the job was added that way.- A disconnecting client only releases deliveries it still owns. Each active job now records the connection and delivery that own it. Stall recovery, orphan recovery, lock expiration, timeouts, management moves and discards detach that ownership, so a client that went silent can no longer release the job’s next delivery to another worker when it finally closes.
- Python SDK: the pytest-based suites need Python 3.10 or later. The test extra pins pytest 9.0.3, which drops Python 3.9; the SDK itself still supports 3.9.
- Periodic cleanup no longer drops long-running jobs that are still alive.
The 10-second cleanup removed every active job started more than 30 minutes
ago from memory without a state change, even while its worker kept sending
heartbeats or renewing its lock, so the worker could no longer acknowledge
it. A job now counts as orphaned only when nothing has been heard from it
(no heartbeat, progress update or lock renewal) for 30 minutes and it holds
no unexpired lock, and the check is repeated under the processing shard’s
write lock. PostgreSQL mode was not affected. Regression:
test/repro-cleanup-heartbeating-active-job.test.ts. backoff.maxDelaynow caps retry delays. The option was part of the job model and the retry calculation, and the Rust, Python and legacy TypeScript SDKs sent it, but job creation dropped it, so every job kept the 1-hour default cap. It is now kept and persisted, andQueue.getJob,Queue.getJobs,FlowProducerresults and embeddedadd()return it injob.opts.backoff; jobs delivered to a TCPWorkerstill expose only the numeric basebackoff, and the server applies the cap either way. Atomic flows reject an invalid value in both modes; embeddedQueue.add/addBulkand scheduler job templates ignore it and keep the default. Regressions:test/repro-backoff-max-delay.test.ts,test/backoff-max-delay-paths.test.ts.- Elixir SDK: the
deduplication:option works. It was sent asdedupwithout auniqueKey, which the server ignores, so two adds with the same deduplication id created two jobs.deduplication.idis now sent asuniqueKeyandttl,extendandreplaceasdedup, as in the other SDKs. An explicit non-emptyuniqueKeystill wins. Regression:sdk/elixir/test/deduplication_options_test.exs. - Orphan recovery releases what the job held. When cleanup recovers an
orphan it now goes through the stall path, which releases the queue
concurrency slot, group slot and unique key, records the attempt in SQLite,
and emits the
stalledevent and webhook. A lease left over from an earlier delivery no longer counts as liveness for the current one. Regressions:test/repro-cleanup-orphan-recovery.test.ts,test/cleanup-orphan-recovery-guards.test.ts. - Recovery sweeps act only on the current delivery. The stall checker now
re-checks that the job is still stalled after taking its locks, so a
heartbeat or new delivery that lands meanwhile keeps the job. Lock expiration
ignores a lease left from an earlier delivery instead of requeuing the newer
one. Overlapping cleanup, stall and lock-expiration sweeps recover a job once.
Regressions:
test/repro-recovered-job-stale-client-release.test.ts,test/repro-recovery-sweeps-current-generation.test.ts,test/repro-orphan-window-respects-stall-config.test.ts. DelayedErrorhonorsbackoff.maxDelayin embedded and TCP mode. The worker re-delayed the job by the base backoff and ignored the cap; TCP workers also dropped the backoff configuration the server sends. Regressions:test/repro-delayed-error-max-delay.test.ts,test/repro-delayed-error-zero-max-delay.test.ts.- Documentation: the Rust SDK README listed APIs that do not exist
(scheduler listing, webhooks, workers, stats, progress reads, children
values), and the Rust and Elixir READMEs claimed
Hellois sent on connect. The store-and-forward guides now state that a re-forward is deduplicated only while the remote job is live; completion or the DLQ releases its custom id. - CI: wider timing margins for a PostgreSQL deduplication test. The
extended-TTL convergence test left the extending push about 40 ms before a
100 ms TTL expired and the contender about 110 ms, including the convergence
wait, before the 180 ms extended TTL ended. Slow PostgreSQL 17 runners hit
both cases and admitted a new job. The windows are now 1000 ms and 2200 ms,
the contender is timed from the moment the first push returned instead of
after the convergence wait, and the test still proves the extension outlives
the original TTL. Regression:
test/postgres-dedup-cache-convergence.test.ts. - CI: a Python SDK E2E test waits for the event it asserts. The ACKB
timeout-suppression test checked the worker’s
completedevents as soon as the broker showed the job completed, but the worker emits the event only after the ACKB reply returns. A loaded Python 3.9 runner polled in between and failed. It now waits for the event and still asserts that only the live job completes. Test:sdk/python/tests/e2e_worker_ack_batch.py.
Security
Section titled “Security”- Documentation site dependencies upgraded. Astro 7.3.5 (from 6.4.8),
Starlight 0.42.5,
@astrojs/react7.0.0,@astrojs/sitemap3.7.4 and sharp 0.35.5 resolve the open advisories, including a critical remote code execution through AVIF image optimization in Astro, base-path authorization bypass and XSS issues in Astro, and libvips/libheif vulnerabilities in sharp. The site build does not process images through sharp, so those paths were not reachable, but the packages are patched. - Python SDK: pytest 9.0.3 for the vulnerable tmpdir handling in earlier versions.
Documentation
Section titled “Documentation”- bunqueue Academy, the free video course, is on the site. A new
/academy/page lists every episode with a player, a summary and the guide it follows, plus the episodes still in production; it is linked from the header and the sidebar. The homepage presents the course directly below the hero, with the 60-second overview, the released lessons and a “New” announcement in the hero. - Videos in the guides. The introduction, quickstart, queue, worker, cron, dead letter queue and flow guides open with their Academy episode, and the dashboard guide and the homepage dashboard section show the dashboard’s full tour. Players load YouTube (privacy-enhanced domain) only when you press play; until then the page serves a local thumbnail.
- Homepage quickstart redesigned. “Your first job, end to end.” is now a segmented switch between the server and embedded paths, each in three numbered steps (start the server, connect your app and worker, see it work) with the expected output. Step 2 has a complete program for every official SDK: Node.js, Bun, Deno, Python, PHP, Go, Rust and Elixir.
- The Go examples compile against the released SDK. Every Go example on the
site that indexes job data (homepage, quickstart, SDK guide, examples, use
cases, cron and flow guides) now does it through
any(job.Data()).(map[string]any), which builds againstsdk/gov0.1.0 (Data()returnsmap[string]any) and v0.2.0 (Data()returnsany). The type assertions used to fail with “is not an interface” for anyone who installed the released module. - README: bunqueue Academy. A new section lists the released episodes with their length and guide, the header links the video course, and the dashboard section links its 8-minute tour.
[2.9.6] - 2026-10-01
Section titled “[2.9.6] - 2026-10-01”Behavior changes
Section titled “Behavior changes”- Unhandled rejections are reported again after a TCP client close.
Applications that relied, knowingly or not, on bunqueue hiding their
unhandled rejections now exit with code 1 after a client
close(), as Bun and Node do by default. Handle the rejection, or register your ownprocess.on('unhandledRejection', ...)listener if you want to keep running; bunqueue defers to it. Details under Client fixes below. - Flat connection options are rejected.
Queue,Worker,FlowProducer,QueueEvents,Bunqueue,SandboxedWorker,QueueGroup.getQueue()/getWorker(), and the workflowEnginenow throw when TCP-mode options carry top-levelhost,port,token, ortls. Those keys were never read, so the client silently connected tolocalhost:6789without the intended token or TLS. Pass them asconnection: { host, port, token, tls }. Embedded mode is unaffected. Regression:test/legacy-connection-options.test.ts.
Client fixes
Section titled “Client fixes”- Unhandled rejections are no longer swallowed after a TCP client close.
TcpClient.close()installed a process-wideunhandledRejectionlistener that ignored every reason exceptClientClosedError. Because any listener disables the Bun/Node default (print the error and exit 1), every unhandled rejection in the host application disappeared after the first close, with exit code 0. The filter still swallows syntheticClientClosedErrorrejections, defers to application-owned listeners, and otherwise removes itself and re-raises the reason so the runtime’s configured mode applies. GlobalSymbol.forbrands let duplicate bunqueue copies in one process share one filter, and a foreign error that is merely namedClientClosedErroris no longer treated as bunqueue’s. Regression:test/repro-client-closed-filter-swallows-app-rejections.test.ts. - A TCP Worker with
limiter.groupKeyno longer FAILs a job that succeeded. A buffered job whose group value cannot be converted to a string (for example{ toString: 'x' }from JSON or msgpack) made the dynamic ACK threshold throw after the ACK was queued. The successful job was then retried, a falsefailedevent was emitted, and the stranded ACK was sent later with a stale token. Theconcurrencysetter could throw for the same reason. Group-value extraction now never throws, keeps existing keys and falls back to a deterministic tag. If the dynamic ACK ceiling throws, the batcher uses the staticbatchSize, keeps the ACK, and reports anerrorevent withcontext: 'ack-threshold'. Regression:test/repro-worker-group-value-poison-ack.test.ts.
TypeScript SDK (bunqueue-client 0.2.0)
Section titled “TypeScript SDK (bunqueue-client 0.2.0)”- Breaking minor release. The default
bunqueue-cliententry is the canonicalbunqueue/clientAPI, and the 0.1.x API stays unchanged atbunqueue-client/legacy. The unreleased version moved from 0.1.10 to 0.2.0 so^0.1.9ranges do not pick up the new API. In TCP mode,pause(),drain(),obliterate()and the other synchronous control calls do not wait for the broker, and synchronous reads return defaults; use the*Asyncvariants. See the migration guide insdk/typescript/README.md#migrating-from-01x. - Queued commands no longer fail with “Connection lost” when the broker starts
after the client under Node, Deno, or Workers. The portable transport now
reports a close only for connections that actually opened, as the Bun
transport does. Regression:
test/tcp-parity-late-broker.test.ts. - The package bundles as CommonJS again: the embedded engine loads lazily and
synchronously under Bun instead of through a top-level
await. Regression:test/client-parity-cjs-bundle.test.ts. - Published declarations no longer reference
bun-typesor leak Bun globals, and the package depends only onmsgpackr. The build fails unless the declarations type-check for a strict NodeNext consumer with@types/node20 or 22 andskipLibCheck: false. - The TLS, security, databases and examples guides and the README now pass
bunqueue-client settings through
connection.
PostgreSQL
Section titled “PostgreSQL”- Storage health recovers after a dropped projection-refresh retry. Lease
renewals and batched completions that apply a job’s committed state, local
claims, and authoritative queue refreshes now clear that job’s
projection-refreshhealth error, and a failedgetJob/getJobStateread queues a background retry. Previously/healthand/readycould report 503 until the job’s next event, or forever for a finished job. Regressions:test/repro-postgres-direct-projection-clears-health.test.tsandtest/postgres-projection-health-recovery.test.ts.
- Every published artifact of a run comes from one verified source commit.
The 2.9.5 npm package and Docker images were built from main commits later
than the
v2.9.5tag (105b8794): manual Docker rebuilds and npm publications checked out main. The version gate now outputssource_sha. It is the pushed commit for a new version, or the existing tag’s commit for a rebuild or npm publication, and the gate fails when that commit’spackage.jsondisagrees. Binaries, images, the npm tarball, and the GitHub release (target_commitish) all use that commit. A rebuild fails on trees without the Docker variant inputs, so v2.9.5 itself cannot be rebuilt by this workflow. Images carry verifiedorg.opencontainers.image.revisionandversionlabels. - The TypeScript SDK release can authenticate.
sdk-release.ymlpassed the token asNODE_AUTH_TOKEN, which Bun ignores. It now usesNPM_CONFIG_TOKEN, runsbun pm whoamiand a dry run of the packed tarball, and then publishes that same tarball. The tag and registry gates fail closed. The--provenanceflag andid-token: writepermission were removed because Bun produces no provenance. - Every third-party GitHub Action is pinned to a full commit SHA, with a comment naming its release, in all workflows.
Documentation
Section titled “Documentation”- Redesign the homepage. The hero leads with adding a job in one language and processing it in another, shown by a simulated queue whose jobs move through waiting, delayed, active, retry and completed (static when reduced motion is requested). Capabilities follow the job lifecycle with matching state colors, storage reads as a path from memory to SQLite to PostgreSQL, and headings use condensed Bricolage Grotesque. The quickstart content is unchanged.
- List public open-source projects that use bunqueue directly below the hero (context-labs/HALO, MagicCube/agentara, dosco/aithy and phuctm97/openkitten) with their GitHub star counts and a link to GitHub’s dependents graph.
- The hero simulation has a Pause button, stops when reduced motion is turned on while the page is open, and is excluded from search snippets.
- Search metadata: the homepage H1 names background jobs again and its meta description fits search results. Fifteen descriptions longer than 160 characters were shortened, 19 titles no longer repeat “bunqueue” in the suffix, URLs without a trailing slash redirect with 308 to the canonical page, current API reference pages gain a social image, and the changelog outline lists releases only.
- Add a Web Dashboard guide for the open-source
bunqueue dashboard:
quick start, capabilities, a connection diagram, the actions that fail
closed, deployment options, configuration variables, the security model for
network access, and the experimental AI Copilot’s data handling. The
homepage introduces the dashboard directly below the hero with a screenshot,
the
bunx bunqueue-dashboardcommand and links to the guide, the live demo and the repository. “Dashboard” is the first link in the header and mobile menu, and the sidebar lists the guide under Run in Production. - Audit the guide, API and architecture pages against the source and fix every
error that could be verified. Examples that did not compile or run now do:
Go
job.Data()indexing without a type assertion, Goworker.Run()used as if it returned an error, untyped TypeScript job data, the Python minimal worker exiting before it processed a job, and Node.js DLQ calls that return nothing over TCP (getDlqAsync,purgeDlqAsync,setDlqConfigAsync). Retry delays in examples and the HTTP reference now follow the real formula (the first retry waits about twice the basebackoff, with jitter, capped at one hour). The HTTP and TCP references gain the real webhook event names, named SSE events,/countand Cancel semantics, the 10,000-entry results cache,/healthreturning 503 when storage is degraded, bulk push keeping jobs accepted before a runtime admission error, and the missingPUSHF,SubscribeEventsandUnsubscribeEventscommands. Two security notes are corrected: a WebSocket connection must send its token in the upgrade request’s header, because the server refuses the upgrade without it (anAuthmessage after connecting never gets that far), and S3 backups rely on the bucket’s default encryption because bunqueue has no encryption option of its own. Type and architecture pages now match the exports, the 38-columnjobstable and six model campaigns;BUNQUEUE_EMBEDDEDis documented as a default rather than an override; the Compose example uses the published image and its/app/datavolume; and the binary install downloads the published archive.
[2.9.5] - 2026-09-09
Section titled “[2.9.5] - 2026-09-09”Docker distribution variants
Section titled “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_TOKENvariable 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
Section titled “Canonical client parity”- Build
bunqueue-clientfrom 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/legacyentry. - 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.
- 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/bunqueuealongside 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
Section titled “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
removeOnCompleteby 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
Section titled “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
PUSHBvalidation 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
Section titled “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 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
Section titled “[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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Setiterator 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
errorlistener now that ACKs can expose the expected transient timeout before reconnection more quickly.
Validation
Section titled “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
Section titled “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
Section titled “[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
Section titled “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.
maxCompletedJobsremains the hot-memory/recovery cap; it is not a disk retention policy. ConfigurecompletedRetentionMswhen automatic expiry is desired. Deleted pages are reused by SQLite, but shrinking an already large file still requires an offlineVACUUMwith 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
Section titled “Documentation”-
Audited the 12 server/operations guide pages and re-audited the Worker pages against the source, correcting:
/healthreturning HTTP 503 with thestorageobject only when degraded; the config-filetimeouts.worker,timeouts.lock, andwebhookskeys documented as currently ignored (env varsWORKER_TIMEOUT_MS,LOCK_TIMEOUT_MS,WEBHOOK_MAX_RETRIES,WEBHOOK_RETRY_DELAY_MSare the working knobs); the JSON log example rewritten to the real{timestamp, level, component, message, data?}shape; the/statsfield list (nofailedkey;memory/collectionsare top-level);skipLockRenewalsuppressing the whole per-job heartbeat (stall freshness included, even withuseLocks: false); SandboxedWorkerpollIntervalpaced 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 fullbatchSizeup front and processing sequentially;cancelJobreturningfalsefor pulled-but-not-started jobs; and the Worker overview statingattemptsas total executions. -
Audited the 24 Queue and Worker guide pages against the source and corrected every claim that contradicted the implementation:
attemptsdocumented as total executions (not retries) and numericbackoffas the exponential base capped at 1h;drain()noted to also remove delayed jobs;getJobCounts()no longer listed as embedded-only;getWaitingChildreninclusiveend; dedupextendrejection when the key owner is no longer pending, and the Node SDK’sgetDeduplicationJobId(); embeddedaddBulkaccepted-prefix behavior under groupmaxSize; the nine previously missingJobOptionsfields in the reference table; PostgreSQLgetRateLimitTtlreturning0;RATE_LIMIT_*env vars described as protocol-level request limiting; stall detection two-phase ~35s timing, backoff-delayed heartbeat-stall retry,max_attempts_exceededprecedence overstalled,job:stalledSSE event name, and external SDKgetDlqreturning raw jobs withoutreason; WorkerremoveOnComplete/removeOnFailnon-boolean values ignored rather than treated asfalse;attemptsMadeas attempts consumed so far; batch processor throw semantics vssetAsFailed(); SandboxedWorkercompleted/failedevents emitted after broker confirmation,maxRestartsoff-by-one budget, and removal of the unverifiable “experimental by bunqueue” wording; Elixir bulkpromote_jobs/1; and the Bun-onlymoveJobToWaitingChildren.
- Added opt-in durable completed-job retention through
storage.completedRetentionMs,BUNQUEUE_COMPLETED_RETENTION_MS, and the--completed-retention-msserver 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 theQueuePro,WorkerPro,QueueEventsPro, andJobProaliases. - 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
Section titled “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_stateconfiguration. PostgreSQL schema version 19 adds aBIGINT CACHE 1grouped-admission sequence,group_order, exact FIFO/rotation indexes, and durable group state for configuration, effective fixed windows, active-capacity calculation, andlast_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
BIGINTfor 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
0to2,097,151at the shared admission boundary for SQLite, TCP, flows, batches, and PostgreSQL.
- 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
undefinedexceptions 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. Thequeue:removedevents 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
removeOnCompleteevidence 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
completedRetentionMsvalues 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
minSizeaccumulation 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 withminSize > limiter.maxnow reject at construction while largersizevalues continue in bounded chunks. - PostgreSQL group
maxSizeadmission 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 (0first, then ascending). - A
Jobreturned from TCPQueue.add()now reflectsgroup.priorityexactly like embedded mode and TCP bulk admission. - Atomic
FlowProducerplanning now preserves group IDs, intra-group priority, andmaxSize; 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
<p>in<h1>and<p>in<p>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-wrapsits 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-wrapon a non-splash hero page now drops that inset, which also keeps pages that continue in.bq-wrapsections (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 bunqueueplusbunx bunqueue-mcp, but thebunqueue-mcpbinary ships insidebunqueueand is not a package of its own. It now matches the MCP guide:bun add -gplusbunx --package=bunqueue bunqueue-mcp. - Quick Start: the Python first snippet called
Worker(...).run()inline, so theworkerhandle used by the later events section did not exist. It now bindsworkerbefore running it. - Documentation site: the nested-paragraph defect also affected
.bq-lede,.bq-chart-title,.bq-vs-sum,.bq-bench-title,.bq-bench-footand.bq-pipeline-captionblocks on the home page, the comparison page, the production page and two blog posts. Every literal<p>/<hN>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-mcpappeared in the home page, the server guide, the cron reference and the env-vars guide.bunqueue-mcpis a binary insidebunqueue, not a package, so the bare form 404s (the MCP guide already documented that). Every call site, includingREADME.md, now usesbunx --package=bunqueue bunqueue-mcp. - Documentation site: deployment, server, env-vars, troubleshooting and the
production blog post configured persistence through
DATA_PATHwhile the env-vars reference documentsBUNQUEUE_DATA_PATHas 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 128langgroups 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 realbunqueue-clientsnippet and every mention names all three. - Documentation site:
guide/queue-groupwas a.mdfile that used<Tabs>and<TabItem>. Plain Markdown does not process components, so the page printed the literalimport { 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-widelangsync (128 groups, one label set). The doc-audit fixtures intest/docs-language-tabs.test.ts,test/docs-queue-snippets.test.tsandtest/documented-feature-coverage.test.tsfollow 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_handleron 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_PATHis the canonical variable andBQ_DATA_PATH,DATA_PATH,SQLITE_PATHare ordered fallbacks. The section now shows the server-mode--data-pathstep and states that a different embeddeddataPaththrows instead of opening a second database. The “Setup” table row also usesbunx 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/addBulknow reopen the Queue’s explicit SQLitedataPathafter 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/clientexports the documented group option types.
Performance
Section titled “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
c39facb9placed 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
Section titled “[2.9.2] - 2026-08-30”- 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
FlowProducergraph. 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?rawimports. The full LLM documentation dump expands the same imports into real fenced source instead of leaking unresolved MDX variables, while the curatedllms.txtlinks the new example hub directly. Documentation builds now end with a discovery validator that comparesllms-full.txtand 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
Section titled “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, andACKBjournal 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
PULLBand retained, result-freeACKBnow 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 andremoveOnCompletebatches 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
Section titled “[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.
- 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
maxQueueEventsprunes 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_watermarkscarries a cumulative, per-queueself_pruned_commit_seqthat 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 newpostgres/eventCatchupCursors.tsowns 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
Section titled “Changed”- The PostgreSQL schema version is now 18. The migration adds
self_pruned_commit_seqand replaces thebunqueue_assign_event_commitcommit-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 withPostgreSQL schema version 18 is newer than supported version 17instead of rewriting the trigger back and silently disabling the fix for every broker.
Fixed (test harness)
Section titled “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 <n> 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
Section titled “[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
Section titled “Release highlights”- PostgreSQL without a compatibility driver. bunqueue uses Bun 1.4’s native
SQLpool, prepared tagged templates, reserved transactions, binary protocol, and reconnectingLISTENsubscription 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 LOCKEDclaims, 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, socronerleaves the published dependency graph andmsgpackris 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
Section titled “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-integerrepeatEveryin 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 >= 1because retained durable events are part of multi-broker convergence. Memory and SQLite retention behavior is unchanged.
- 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 LOCKEDclaims, fenced database-clock leases, durable events, queue limits, cron schedules, worker registrations, logs, metrics, dependencies, repeat successors, and DLQ lifecycle state. A pinned two-brokerdocker-compose.postgres.ymltopology 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 connectQueue,Worker,QueueEvents, andFlowProducerto different members of a four-process cluster. They cover lifecycle/results/logs, pause, custom-ID idempotency, DLQ retry,removeOnCompleteresult 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
SIGKILLwhen 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)andremoveDlqJobAsync(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
Section titled “Changed”- Dedicated PostgreSQL test commands now reject a missing or blank
BUNQUEUE_TEST_POSTGRES_URLinstead 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
SQLclient 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.croneris no longer a published runtime dependency, leavingmsgpackras the only one. Seven-field years and the previously accidentalL,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
memoryconstruction ignores an inherited SQLite data path; the SQLite Strategy and hot path remain unchanged. - PostgreSQL
WaitJobnow recognizes durable completion-only generations when a queued request reaches the broker afterremoveOnCompletedeleted the live row. A discriminated asynchronous completion port preserves validundefinedresults, performs one authoritative PostgreSQL read, and leaves the existing memory/SQLite missing-row behavior unchanged. - PostgreSQL
IsPausednow 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 ONLYsnapshot. 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
removeOnCompletetombstones keep the newestmaxCompletedJobs, the compatibility snapshot independently caps completed rows and itsmaxJobResultsLRU, 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 rejectsmaxQueueEvents: 0explicitly 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_runusing 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
Section titled “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
prevTSor 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_memsweeps, 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 LOCKEDplan 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
PULLBinstead 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_statementscampaign 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 atshared_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.
-
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.
PUSHFnow reuses its complete outer lock plan, samples one post-lock transition timestamp, and batches orderedpushedevents 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 forced55P03replay 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, or55P03errors. 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
hashtextcollisions. 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
GetResultandWaitJobnow read completion results through an asynchronous authoritative result port. A broker waiting on work completed by another broker can no longer returncompleted: truewith 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
maxAgepurge 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
brokerIdvalues 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 ONLYsnapshots, 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
removeUnprocessedChildrennow 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
40P01deadlocks, 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:detailresponses for SQLite queues that only have configuration state and no jobs. The async adapter now requests the named queue explicitly, sostallConfig.enabledand 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:getretain 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;
obliteratetakes 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 livewaiting-childrenjob 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
removeOnCompletenow 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 originalpushedpayload without reorderingpushed/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_degradedreport 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
MoveToWaitnow 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
40P01deadlock. 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_PASSWORDinto broker URLs. Operators provide a separately percent-encodedBUNQUEUE_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 1sequence 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
BIGSERIALgap 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
Section titled “[2.8.61] - 2026-08-22”- 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
Section titled “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
Section titled “[2.8.60] - 2026-08-22”- 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
Section titled “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
Section titled “[2.8.59] - 2026-08-05”- 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
Section titled “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
Section titled “[2.8.58] - 2026-08-04”- Queue now releases its constructor-owned shared TCP pool reference exactly
once, so duplicate
close()calls anddisconnect()followed byclose()cannot break unrelated Queue instances. Worker shutdown is also monotonic: stalerun()orresume()callbacks are ignored afterclose()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: 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
dataPathconflicts 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 preventsdurable: truejobs from being accepted into memory after an earlier client initialized the singleton without storage.
Removed
Section titled “Removed”- Removed StrykerJS from the TypeScript SDK — mutation job, config, script,
dev dependency and the
qsoverride it needed. Its dependency graph produced every finding the weekly advisory gate reported (qsviatyped-rest-client, thenfast-uriviaajv), 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 cleanbun audit.
[2.8.57] - 2026-08-03
Section titled “[2.8.57] - 2026-08-03”CI and package verification
Section titled “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()withshutdownManager(), 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
defineConfigfrombunqueue, Queue and Worker frombunqueue/client, and Engine frombunqueue/workflowafter packing the release. The consumer unpacks the archive into its ownnode_modulesand links only the declared dependencies, provingcronerandmsgpackrare 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
PATHwhile 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 noqsdependency.
Do not use 2.8.56
Section titled “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)
Section titled “[2.8.56] - 2026-08-03 (do not use)”Engine correctness
Section titled “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/durationis 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.namewithout adding or consumingdata.name, while schema migrations retain a bounded fallback for legacy name envelopes. - Populated
returnvalueandfailedReasonconsistently on embedded and TCP reads, preservingnulland every falsy result. Worker retention options now apply remotely, DLQ statistics includebyQueue, 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
returnvaluenor 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 closedduring recovery or teardown. - Made late outcomes from lock-expired
preventOverlapcron 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, manualmoveToFailed(), or sandbox result for that retired generation returns structured ignored evidence and emits no false localcompleted,failed, or Workererrorevent. 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, retainupdateData()propagation, and continue across SQLite-backed broker restarts. Interval offsets establish a future first-successor phase without makingimmediatelyrecur. Schema v34 persists repeat and advanced generation policy injobs.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/__parentQueuealongside__flowParentId, including cross-queue and restart recovery. Worker,getJob, and list reads expose engine-ownedFlowJobDatawithout reintroducing the historical name envelope. FlowupdateData()atomically preserves every topology field, rejects reserved-key forgery over embedded and raw TCP paths, and still permits unrelated__customkeys on ordinary jobs. - Made
FlowProducer.closingmeaningful and failure-safe: it isnullwhile 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 + timeoutin 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 } })andaddBulk()create durable, atomic dependency edges to existing pending parents in embedded and TCP modes, including cross-queue and restart recovery. Bare protocolparentIdforward references remain compatible with legacy flow construction. - Preserved complete
SchedulerInfovalues fromupsertJobScheduler()in both embedded and TCP modes: immediate results now retainpattern/everyand use the exact scheduler or brokernextRunvalue 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: -1reads the remainder, andascdefaults tofalse. - 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
CronJobvalues. Embedded and TCP MCP backends now expose identical optional cron fields instead of leaking protocolnullvalues 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
undefinedwhile an explicitly completednullresult remainsnull. 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()andJob.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(), andmoveToWaitingChildren()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. SynchronousJob.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.Discardnow 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 cleanManualJobwith first-classname, typed userdata, 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, andclose()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. AfterEngine.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_PORTendpoints 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 fromfinally, 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 validatedBENCH_TIMEOUT_MSinput 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 entrypointfinally, 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 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
stalledevent beforefailedin embedded and TCP modes. - Unified public Job lifecycle metadata for direct and list queries. Embedded
and TCP proxies, reflected properties,
toJSON(), andasJSON()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
Section titled “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
stalledevent as embedded Workers. - Made
FlowProducer.getParentResult()andgetParentResults()authoritative over TCP while preserving synchronous embedded compatibility, and fixedQueue.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
Section titled “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
Section titled “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
ignoredIndicesfor ACK batches, remain correct with duplicate job IDs, and reject ambiguous or malformed evidence.
Documentation and verification
Section titled “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
skepticreviewer 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
amd64coverage; 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-finalizedresult. - 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.failingor 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
Section titled “[2.8.55] - 2026-08-01”Engine correctness
Section titled “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
dlqtojobsatomically and durably removes capacity-evicted entries. - Classified processing timeouts as
timeoutthrough retry history and the final DLQ entry without changing the public failure API or contaminating a later explicit processor failure. - Wired the cloud
s3:backupcommand 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
Section titled “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
Section titled “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
Section titled “[2.8.54] - 2026-08-01”Public API completeness
Section titled “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
Jobto 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
Section titled “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
Joboperation. 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
Section titled “[2.8.53] - 2026-07-31”- The TCP protocol audit now asks the kernel to bind an available port and
reads the assigned listener port before connecting, eliminating the
EADDRINUSErace 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: 0listener contract.
[2.8.52] - 2026-07-31
Section titled “[2.8.52] - 2026-07-31”- The binary-build workflow now quotes the GitHub Actions command-file path
used for version outputs, resolving the
SC2086failure 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
Section titled “[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
Section titled “FlowProducer and SDKs”- All six external SDKs now preallocate complete flow graphs and commit them
with one broker-side
PUSHFcommand. 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
UpdateParentcalls remain compatible when a declared child finishes before the backpatch. Completed, active, failed andremoveOnCompleteraces update only the child’s ownership; parent topology and scheduling never transition twice. Durable DLQ data and theflow_failuresoutbox 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.
removeOnCompleteACK, 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.
- 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/mainenforcement, frozen lockfile, package-content validation, provenance, preflight registry/tag checks and tag creation only afterbun publishsucceeds. - 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
Section titled “[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.
- 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
retrybudget. - Workflow timeouts accept every
PromiseLikeand 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 anAbortSignalso 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)
Section titled “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
Section titled “FlowProducer”- Every Bun flow creation API is now atomic.
add,addBulk,addChain,addBulkThenandaddTreepreallocate the final IDs and send onePUSHFgraph. 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_failuresoutbox 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.
getFlowhonors 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
prioritizedandwaiting-childreninstead of flattening both towaiting, keeping recovery diagnostics and persistence invariants coherent with the public state.
Documentation and verification
Section titled “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
Section titled “[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.
- Vercel rejected the documentation deployment before Astro could start.
Explanatory text for the two
X-Robots-Tagroutes had been encoded as synthetic"//"properties insidevercel.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 is100%, 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-widthcolumn 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-wraphanded the gutter back — but 81 of 83 pages open with a.bq-heroand 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-gridcollapsed 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 the290px + 1frgrid (plus the nested1fr 1frrow) 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
<h1>elements. Starlight renders the frontmatter title as the page<h1>; on the 81 hero pages custom CSS hides the panel containing it, so the keyword-bearing heading lived in adisplay: nonesubtree while the hero supplied a second<h1>. APageTitleoverride keeps the real<h1>on pages that actually display it and downgrades it to a<div>(same#_topanchor, which the TOC links to) where the hero already owns the heading.architecture/model-based-testingalso had a redundant#heading duplicating its own title; removed. - The simulator skipped from
<h1>to<h3>. Its nine panel titles are now<h2>, 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
Section titled “Changed”X-Robots-Tag: noindex, followfor the versioned TypeDoc dump under/reference/v<version>/. 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) getnoindexfor the same duplicate-content reason;llms.txtandllms-full.txtare unaffected.- Article structured data now carries a real
datePublished, taken from each page’s first commit, alongside the existing git-deriveddateModified. Blog posts keep their frontmatter publication date and takedateModifiedfrom git rather than repeating the publication date. twitter:image(and its alt text) is now emitted on every page. X fell back toog:image, but the Card Validator checks for the explicit tag.
[2.8.48] - 2026-07-30
Section titled “[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.
-
The docs site could not build from a clean checkout.
.gitignoreexcludeddata/for runtime SQLite directories, and that pattern also matcheddocs/src/data/, so the generatedapiVersions.jsonthatreference.mdximports was never committed. Local builds succeeded from the file on disk; CI failed withCould 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 testpanics at its own 10-minute default, which is shorter than the 15-minute soak profile, so the job died atpanic: test timed out after 10m0severy 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.tscalled its two-parameterbanner()with three arguments, so the depth parameter received a boolean, collapsed to0, and every page below the version root linked back to that version instead of/reference/. The link is now derived byallVersionsHref(depth), the already-published pages were repaired in place, andtest/build-api-reference.test.tspins the arithmetic and the signature.scripts/is outsidetsconfig.json’sinclude, 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_SECONDSplus 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 defaultbash -e(noset -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
Section titled “Changed”- The SDK soak profiles can now be run on demand.
.github/workflows/sdk.ymlgainsworkflow_dispatchwith arun_soakinput; previously the soak steps (and the Go native fuzzing step, now gated the same way) ran onschedulealone, so a fix to them could not be exercised before the next Sunday.RATE_LIMIT_MAX_REQUESTSis 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-minutesbound (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.
bun run check:docs-data(scripts/check-docs-data.ts), wired into the CI docs job ahead of the build and intobun run check. It asserts that every relative module specifier (from, side-effectimport, dynamicimport(),require) and asset reference (markdown image,src=) indocs/src/content/docs/**resolves to a git-tracked file, and that the committedapiVersions.jsonstill equals what the generator would derive frompackage.jsonanddocs/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. Adeventry is rejected outright so a local--devpreview cannot be published. Note that a minor bump now fails the check untilbun run docs:apioutput is committed; patch bumps are unaffected. Scanners are unit-tested intest/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 untrackedx.jscannot hide behind a trackedx.ts.test/sdk-ci-workflow.test.tsnow asserts the soak invariants instead of a literal env string. It parsessdk.ymland requires thatRUN_SOAKandRATE_LIMIT_MAX_REQUESTSderive 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
Section titled “[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.
-
A
doUntilordoWhileiteration 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 reportedrollbackStatus: '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 byabandonCompensation, which walks the same set, so no operator action could give it an outcome.forEachwas never affected. -
A sub-workflow that failed, parked or timed out was dropped from its parent’s rollback. The
sub:record was writtenrunningbefore the wait andcompletedonly on success, so every other outcome left it in flight, and the unwind skips anything that is neithercompletednorfailed. 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 settledfailed, the parent inherits the child’s park, andresumeCompensation()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_BUSYescaped the whole pass and left the runcompensating:resumeCompensationandabandonCompensationboth requirecompensation-stuck, so neither was available, while recovery does pickcompensatingruns 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 thecompensatingtransition 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
completedwith 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
finallywhile the step’s exception was propagating and an exception from afinallysupersedes 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: 0was accepted and produced a TypeError as the failure reason.retryis 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’sfailureReasonbecameundefined is not an object (...), where an operator looks for what happened. In a resumed loop it wrote afailedrecord for a handler that was never called, which the rollback then reversed. It is now refused where it is written, bystep()and byforEach(), 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-failedand 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: trueinsummary.jsonfor 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 wrotecompensation-failedover 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
compensatehandler 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. TheAggregateErrorcarried every failure, but the persistedfailureReasontook 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 reads2 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-failedfrom 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 stayscompensating, recovery drives it again, and it endedrollbackStatus: 'completed'with a reversal still sitting incompensation-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 satrunningforever 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 markedcompensating, 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 wroterollbackStatus: '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 athrow { code: 502, detail: ... }from an HTTP client is ordinary. The result was stored on a run parked incompensation-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 plainINSERT: 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.signalsis a plain object used as a map and presence was asked within, 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,hasOwnPropertyand the rest behaved the same, and an event name read from config or user input is attacker-influenced. Presence is now asked withObject.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/outputSchemaare documented with Zod, andparse()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 calledparse()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:undefinedmeans “assert only” and the original value is kept. -
signal(id, event)with no payload no longer does nothing.payloadis optional, so the most idiomatic human-in-the-loop call, “the approver said go”, nothing to carry, was recorded assignals[event] = undefined. The codec runs withstructuredClone: trueand round-tripsundefinedfaithfully, so the key was present but the value was not, and every presence test askedsignals[event] !== undefined, which is a value test. The engine’s two halves then disagreed:record()claimed the resume and re-enqueued the node, and thewaitForit 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, thewaitForpre-check, thewaitFortimeout re-read, and the crash-recovery resume. An explicitnullpayload always worked and still does. (workflow/storeSignals.ts,waitFor.ts,recovery.ts,test/repro-workflow-signal-no-payload.test.ts) -
A parked
waitForno longer keeps the process alive. Clamping long waits tosetTimeout’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 afterclose(). Timers are unref’d, andEngine.close()releases them. -
abandonCompensationleft sub-workflows with no outcome. It decided eligibility withfindStepDef(), which walks step nodes only, so everysub:record finished an abandoned unwind withcompensation === 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
completedthat 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/doWhileis now compensated, not only the last. Loop bodies were matched by exact name, soturn:0,turn:1and 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:extraalongside a loop body calledchargewas resolved to the loop’s definition, so the loop’s rollback ran twice and its own never ran. Only a numeric:<digits>suffix, the one this engine generates, is structural, and the match is anchored: a loop body namedcharge:extraproducescharge:extra:1, which is an iteration ofcharge:extraand not ofcharge. Unanchored, that record rancharge’s reversal four times, never ran its own, and still reportedrollbackStatus: '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 incompensatingrather thancompensation-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
subWorkflowis 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, seed1267197984. -
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
runNodecaller set the final state, so a persistedcompensatingrun whose steps no longer resolve, after a deploy renamed one, came back fromlistRecoverable()at every startup and was re-driven forever. -
timeout: 0left 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 ofrecover(),resumeCompensation()andabandonCompensation()for the life of the process. A reversal now falls back to 30000 ms. -
cleanup(0)andarchive(0)archived nothing when a run had just finished. Both filtered with a strictupdated_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_BUSYcould surface fromsignal(). 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 nobusy_timeout. It is now 5 s.
-
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) andadmission.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). EveryDate.now(),Math.random()and timer insrc/client/workflow/now reads from it, and the real clock is the default, so nothing changes unless a test installssimulatedClock(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-stuckstate, withengine.resumeCompensation()andengine.abandonCompensation()as the ways out;.pivot()marks a point of no return past which nothing is rolled back; androllbackStatusis tracked separately fromfailureReason, because “the payment failed” and “the refund never went through” need different alerts. -
Idempotency keys on every step, shaped
run:step#occurrence:directionand invariant across retries and crash-resume. Compensate handlers also receivectx.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/<version>/, generated from source with TypeDoc over the package’s ownexportsmap. Build it withbun 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
ExecutionandStepRecordare now exported and nameable:RollbackStatus,CompensationStatus,CompensationOutcome,BranchConditionandWorkflowNode.
Changed (experimental API)
Section titled “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 andsignal:receivedwas 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, atregister()and atsignal(). 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
waitFornodes on the same event are now rejected atregister(). A delivered signal is never consumed, and a wait is satisfied by the event key being present, so a run shapedwaitFor('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. AwaitForwith 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 nowasync. 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 beforeallSettledwas ever called instead of settling. It now matches its sibling. -
forEachnow rejects a non-array item source. It read.lengthand indexed directly, and JavaScript is generous about what has a length: a number iterated ZERO times and still reportedcompleted, 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. Onlynullandundefinedfailed, 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:indexnamespace, e.g. a step calledturn:0beside a loop body calledturn. Harmless before because loops did not write indexed records; this release introduces them, and the collision would overwrite an iteration’s history.
- a
-
ExecutionStategainedcompensation-stuck. An exhaustiveswitchover 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 implicitanyand failed undernoImplicitAny. 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 thatctx.stepsinside a rollback is not narrowed to the accumulated step types; handlers that want that annotate their own parameter.
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
Section titled “[2.8.46] - 2026-07-22”Fixed: hardware-independent banner regression coverage
Section titled “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
Section titled “[2.8.45] - 2026-07-22”Changed: clearer polyglot startup identity
Section titled “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
Section titled “[2.8.44] - 2026-07-20”Fixed: transaction-safe S3 recovery and production monitoring
Section titled “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_secondshistograms, 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 withMETRICS_MAX_QUEUESortelemetry.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
Section titled “[2.8.43] - 2026-07-19”Fixed: collision-free SDK broker fixtures
Section titled “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
Section titled “[2.8.42] - 2026-07-19”Fixed: reliable scheduled SDK soak tests
Section titled “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/1returns: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
Section titled “[2.8.41] - 2026-07-19”Performance: batch pulls scan ineligible jobs once
Section titled “Performance: batch pulls scan ineligible jobs once”pullBatchnow 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
Section titled “[2.8.40] - 2026-07-19”Fixed: deterministic and interruption-safe CLI
Section titled “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
pingandbackup create. --jsonemits 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
Section titled “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
Section titled “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
Section titled “[2.8.39] - 2026-07-18”Fixed: overload correlation and complete stale-dependency cleanup
Section titled “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
reqIdso 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
Section titled “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
nullresult 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
Section titled “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
Section titled “[2.8.38] - 2026-07-18”Changed: adoption-first npm README
Section titled “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
Section titled “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
Section titled “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
Section titled “[2.8.37] - 2026-07-17”Fixed: Go SDK conformance in CI
Section titled “Fixed: Go SDK conformance in CI”- The Go conformance job now enters the nested driver module explicitly with
go -C drivers/go run .. Runninggo run ./drivers/gofromsdk/conformancesearched for a parentgo.modand 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:sandboxand cannot silently drift. - The late-dependency TCP regression now delegates port allocation to the
kernel instead of guessing an unreserved high port, eliminating an
EADDRINUSErace 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
Section titled “[2.8.36] - 2026-07-17”Added: real-broker model-based state machine
Section titled “Added: real-broker model-based state machine”- A
fast-checkasynchronous command model now generates lifecycle, batch, dependency, DLQ, limiter, queue-control, and actualSIGKILL/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 viabun run test:model. - The first campaign found and permanently covers two crash-durability bugs:
Updatechanged payload only in memory, andChangePriorityfailed 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
stallCountand the queue’s complete custom stall policy before classifying active work. Recovery, heartbeat stalls, and expired locks all enforce bothmaxAttemptsandmaxStalls; 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
GetStateor 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 thewaiting-childrenbucket, 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 activeDiscard) now release both the live lease and TCP-client ownership through one idempotent cleanup. No stale lock or client tracking remains after the job leavesactive. - The full sandbox gate exposed a tenth persistence-boundary defect after
adding durable stall counters: legacy/low-level jobs without
stallCountviolated the new SQLiteNOT NULLcolumn. 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
Section titled “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:sdkbuilds 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
Section titled “[2.8.35] - 2026-07-17”Added: awaitable client API variants
Section titled “Added: awaitable client API variants”- New
Asyncvariants onQueuethat resolve only after the server has processed the command:obliterateAsync(),pauseAsync(),resumeAsync(),drainAsync()(returns the removed count),retryDlqAsync(id?)andpurgeDlqAsync()(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 existingDlqcommand (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 byadd()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
Section titled “Fixed: rate-limit duration honored end-to-end”queue.setGlobalRateLimit(max, duration?)now means “maxjobs perdurationms” in both embedded and TCP modes (default stays 1 second). TheRateLimitwire command andPUT /queues/:queue/rate-limitgain optionalduration; invalid values degrade to the defaults instead of failing.- Previously the client accepted
durationfor BullMQ compatibility and silently dropped it: “100 per minute” behaved as “100 per second”.
Fixed: temporary rate limit expires broker-side
Section titled “Fixed: temporary rate limit expires broker-side”queue.rateLimit(expireTimeMs)now sets the limit with a broker-sidettl: 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 addqueue_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
Section titled “Fixed: skipped cron fires no longer consume the maxLimit budget”- The scheduler incremented and persisted
executionsbefore the skip checks (skipIfNoWorker, overlap guard), so a skipped fire burned one run of the cap without pushing a job — askipIfNoWorkercron with no worker could exhaust its entire budget with zero deliveries. Skip decisions now run before the increment: a skip advances the schedule and emitscron:skippedbut leaves the budget untouched, soexecutionscounts actual deliveries.
Fixed: two flaky TCP integration tests
Section titled “Fixed: two flaky TCP integration tests”test-flow-advancedandtest-frameparser-pipelining-e2eraced their fire-and-forgetobliterate()against the jobs they pushed right after (the CI failures on July 17); both now awaitobliterateAsync(). The 20k-row recovery regression test gained an explicit timeout for slow CI runners.
[2.8.34] - 2026-07-17
Section titled “[2.8.34] - 2026-07-17”Added: isolated parallel test gate with engineering telemetry
Section titled “Added: isolated parallel test gate with engineering telemetry”bun run test:sandboxbuilds 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
Section titled “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 promotedrun_atbefore 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
Section titled “[2.8.33] - 2026-07-16”Fixed: recovery, job pagination, and FIFO-group scheduling correctness
Section titled “Fixed: recovery, job pagination, and FIFO-group scheduling correctness”- SQLite startup recovery no longer skips the row that crosses a shrinking
OFFSETpage 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; logicalwaiting,prioritized, anddelayedpredicates execute in SQL beforeLIMIT/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
nullmerely 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
Section titled “Improved: summaries, temporal indexes, waiters, and delayed-heap memory”- Queue summaries use one aggregation pass and include
prioritizedjobs 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
Section titled “[2.8.32] - 2026-07-11”Fixed: permanent concurrency-slot leak in moveJobToDelayed and discardJob
Section titled “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)
Section titled “Fixed: three “client or handler silently drops a supported option” siblings (#111 class)”TcpConnectionPoolacceptedmaxInFlightandpipeliningbut 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.PUSHBskipped the validation singlePUSHenforces: an out-of-bounds option or adependsOnpointing 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
PULLwithout worker locks ignored itstimeout, returning immediately instead of long-polling (workers usinguseLocks: falsewere busy-polling without knowing). The timeout is now validated and honored exactly like singlePULL.
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
Section titled “[2.8.31] - 2026-07-10”Fixed: getJob/GetState returned a false null during the pull transition window
Section titled “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
Section titled “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
Section titled “[2.8.30] - 2026-07-10”Added: “24/7 readiness” battle-testing suites (adversarial, no source changes)
Section titled “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-jobIdPUSH → 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), underSIGKILL: 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), gracefulSIGTERMflushes 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);jobResultsand the custom-id dedup map stay bounded under pressure; the DLQ is bounded tomaxEntriesand remains retryable.
Documented in docs/architecture.md (new Reliability & Battle-Testing section). No runtime/API changes.
[2.8.29] - 2026-07-09
Section titled “[2.8.29] - 2026-07-09”Fixed: upsertJobScheduler silently dropped limit (#111, thanks @jdorner)
Section titled “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
Section titled “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 })ignoredcount: the client sent it (or, in the SDKs, explicitly dropped it) but theRetryDlqcommand/handler had nocountfield, so the entire DLQ was retried instead of the requested N. Addedcountend-to-end (wire → handler →retryDlq(queue, jobId?, limit?)→ a boundedretryDlqJobsthat reuses the tested per-entryretryDlqJob, leaving the remainder in the DLQ). The Python and TypeScript SDKs now forwardcounttoo (forward-compatible: older servers ignore it).Queue.moveJobToFailed()dropped the stacktrace andUnrecoverableError: the Queue reflection API and the job proxies sent only{ error: message }, losing the stack (#74 sibling) and treating anUnrecoverableErroras a normal retryable failure. All four client failure sites (jobMove, two job proxies, the flow job proxy, the sandboxed worker) now route through a sharedfailWirehelper that mirrors the worker path (stack+unrecoverable).Worker.getNextJob()ignoredlockDuration: the manual-acquire API used the server-default lock TTL on both the embedded and TCP paths, silently discarding a customlockDuration(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
Section titled “[2.8.28] - 2026-07-09”Fixed: lock-expiry DLQ move was never persisted to SQLite (#110, root cause of #97’s re-repros)
Section titled “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)
Section titled “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
Section titled “[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)
Section titled “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)
Section titled “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
Section titled “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
Section titled “[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)
Section titled “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)
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “[2.8.25] - 2026-06-29”Fixed: finishedOn/processedOn always undefined on jobs from list queries (#104)
Section titled “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.tsfor all three affected sites:getJobs(embedded),getJobsAsync(TCP), andgetJob(id)over TCP, the last was the inverse of the same gap (its TCP branch patched onlyprogress, so post-fix it would have disagreed withgetJobs). All now mirrorbuildJobProperties: a numeric timestamp maps through,null→undefined(guarded bytypeof === 'number'). - The failure path is intentionally untouched: a failed job has no
completedAt(only the success path sets it;completedAtdoubles as a “completed” signal in cloud state classification andwaitUntilFinished), sofinishedOnstaysundefinedfor failed jobs in bothgetJobandgetJobs, consistent. A failed job’sprocessedOnis populated (it was started), matchinggetJob. - Tests:
test/repro-issue104-getjobs-finishedon.test.ts(6 embedded cases incl. parity withgetJob, a populated failed-jobprocessedOn, and a negative still-waiting case) and a new TCP integration case inscripts/tcp/test-query-operations.ts(“finishedOn/processedOn over TCP”) exercising the real wire path for bothgetJobsAsyncandgetJob(id).
[2.8.24] - 2026-06-27
Section titled “[2.8.24] - 2026-06-27”Performance: TCP frame parser made linear (O(F²) → O(F)) under pipelining
Section titled “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-benchround-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
FrameSizeErrorguard, and the slowlorishasPartialFrame/bufferedBytesgetters are preserved. New E2E suitescripts/tcp/test-frameparser-pipelining-e2e.tsvalidates 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
Section titled “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).addDataalready 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. cleanEmptyQueuesO(Q²) → O(Q) (src/application/cleanupTasks.ts). The 10s background sweep called theshard.dlqgetter, which rebuilds aMapof 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
Section titled “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
Section titled “[2.8.23] - 2026-06-24”Fixed: FlowProducer audit: two real defects (cross-queue parent linkage + addBulkThen result access)
Section titled “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:
-
updateJobParentcorrupted a child’s__parentQueue(src/application/queueManager.ts). It setdata.__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’sQueue.getJob(...).parent.queueQualifiedName,.parentKey,.opts.parent.queue, andtoJSON()/asJSON().parentKeyall reportedCinstead ofP, breaking child→parent navigation. (Execution,getChildrenValues,getFlow, andfailParentOnFailurewere unaffected, they key off the domainparentId, which was already correct, so same-queue flows masked the bug.) Now set fromparentJob.queue.Queue.add({ parent })already did this correctly (add.ts);updateJobParentwas the lone divergence. -
addBulkThenproduced a merge job that could not read its predecessors (src/client/flow.ts). Thefinaljob was pushed viapushJobwithdependsOn = parallelIdsbut nochildrenIds, sogetChildrenValues(finalId)returned{}, incompatible with BullMQ fan-in and withadd(). It now pushes the final job viapushJobWithParent(children = the parallel ids): identicaldependsOnordering (still waits for all parallel jobs), but the merge step can now read their results viagetChildrenValues()/getDependencies(). Note the linkage side effect: the parallel jobs now have the final job as theirparentId, so a parallel step carryingfailParentOnFailure: truewill 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
Section titled “[2.8.22] - 2026-06-23”Fixed: cancel() / removeAsync() did not remove flow-chain jobs parked in waitingDeps (#102)
Section titled “Fixed: cancel() / removeAsync() did not remove flow-chain jobs parked in waitingDeps (#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
Section titled “[2.8.21] - 2026-06-23”Performance: eliminated two O(n²) hot paths in batch push (addBulk up to 32× faster over TCP)
Section titled “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 aSkipListordered bycreatedAtwith jobId-based deduplication. In a bulk pushnowis captured once, so every job in the batch shares the samecreatedAt, making every node compare-equal, which (a) turnedSkipList.insert’s duplicate-check scan into O(n) per insert ⇒ O(n²) per batch, and (b) madeSkipList.deleteremove the WRONG same-createdAtnode (it stopped at the first compare-equal node, a latent correctness bug inremoveFromIndex). 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-createdAtinserts: 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 bothwsHandlerandsseHandlerto every job event.wsHandler.broadcastearly-returns when no clients are connected, butsseHandler.broadcastdid not, so everypushedevent still paidJSON.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 mirroringwsHandler: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
Section titled “[2.8.20] - 2026-06-17”Fixed: embedded job.remove() / removeAsync() did not await the cancellation (RED→GREEN reproduction)
Section titled “Fixed: embedded job.remove() / removeAsync() did not await the cancellation (RED→GREEN reproduction)”Queue.removeAsync()(which backs the BullMQ-stylejob.remove()) returned before the job was actually removed, on the embedded path (src/client/queue/operations/management.ts): the embedded branch firedgetSharedManager().cancel(id)as a floating promise andreturned immediately, while the TCP branch correctlyawaited itsCancelsend. Becausecancel()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 nowawaitscancel(). Surfaced by the new BiomenoFloatingPromiseslint (the old code was hidden behind a file-leveleslint-disable no-floating-promises). Deterministic repro via lock contention intest/repro-removeasync-floating-cancel.test.ts. The synchronousremove()remains intentionally fire-and-forget.
[2.8.19] - 2026-06-17
Section titled “[2.8.19] - 2026-06-17”Fixed: a successful completion was lost when the lock expired mid-processing (#101; RED→GREEN reproduction)
Section titled “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
failedwhen its lock token expired while the handler was running (src/application/queueManager.ts): whenlockDurationelapsed 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 clientAckBatcherburned its transient retries against this permanent error and dropped the completion, and the job re-pulled → stalled → landed infaileddespite having been processed correctly every time (observed ~350 jobs and 695×acks loston one production queue). The ACK paths (ack,ackBatch,ackBatchWithResults) now apply a grace window: a completion is accepted when the job is still inprocessing, 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 viaactiveJobIds, and the lock preserves the original owner’s recovery path), so if another worker re-pulls the job itsstartedAtis reset to a newer time than the lingering lock’screatedAt, 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)
Section titled “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/): thepaused/rateLimit/concurrencyLimitstate lived only inLimiterManager’s in-memory Map. The schema declared aqueue_statetable 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/clearConcurrencywrite through toqueue_state(UPSERT; an all-default state deletes the row instead of persisting a placeholder),obliteratedrops the row, andrecover()loadsqueue_stateon boot and applies it to the owning shard. Control-state now survives restarts/upgrades/crashes.
[2.8.18] - 2026-06-16
Section titled “[2.8.18] - 2026-06-16”Fixed: Worker over-pulled (leased) jobs past concurrency (#98; RED→GREEN reproduction)
Section titled “Fixed: Worker over-pulled (leased) jobs past concurrency (#98; RED→GREEN reproduction)”- A Worker leased more jobs than
concurrency, inflating the broker’sactivecount and starving other workers (src/client/worker/worker.ts): the #96 fix capped execution (activeJobs) atconcurrency, butdoPullBatch()computed free slots asconcurrency - activeJobsand then awaited the pull with no reservation. Two leaks compounded: (1) several concurrentfinally → poll → tryProcessruns each read the same staleactiveJobsand each pulled a full batch; (2) a job just pulled by one run sat in the localpendingJobsbuffer, leased and kept alive by the heartbeat (which renews locks for allpulledJobIds, not just running ones), but not yet inactiveJobs, so an overlapping pull never saw it. Withconcurrency: 3the worker held 5-6 jobs leased (3 running + buffered).doPullBatch()now caps the leased count (running + buffered + in-flight pulls): a newpendingPullcounter reserves slots before the await (released infinally), and free slots are computed frompulledJobIds.size(the true leased set) instead ofactiveJobs. 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 inflatedactivecount, and head-of-line fairness across workers.
[2.8.17] - 2026-06-16
Section titled “[2.8.17] - 2026-06-16”Fixed: retry of a lock-expiry failure threw UNIQUE constraint failed: jobs.id (#97; RED→GREEN reproduction)
Section titled “Fixed: retry of a lock-expiry failure threw UNIQUE constraint failed: jobs.id (#97; RED→GREEN reproduction)”- Retrying a job that reached
failedthrough the lock-expiry path failed withUNIQUE constraint failed: jobs.id(src/application/lockManager.ts):handleMaxStallsExceededmoved 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 inbackgroundTasks, it never calledstorage.saveDlqEntry(entry)norstorage.deleteJob(jobId). So thejobsrow survived in SQLite as an orphan (stateactive) and the DLQ entry lived only in memory. On retry,dlqManager.retryDlqJobre-INSERTs the job with its original id via the plainINSERT INTO jobsstatement (notINSERT OR REPLACElikeinsertResult/insertCron), and the surviving orphan row raised the UNIQUE violation, failing the retry; a restart in that window also re-recovered the staleactiverow. The lock-expiry DLQ move now persists like its siblings (capture theDlqEntry, thensaveDlqEntry+deleteJob), restoring the single-table-residency invariant.deleteJobalso 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
Section titled “[2.8.16] - 2026-06-16”Fixed: stale-ACK timeout resurrection (defect 3 from the destruction-validation audit; RED→GREEN reproduction)
Section titled “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-jobtimeoutandattempts > 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 areattempts > 0in queue). The timeout sweep now records the job in a boundedtimedOutJobsset; 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
Section titled “[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)
Section titled “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 syncgetJobCounts()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 awaitablePromise<JobCounts>with the real counts); embedded mode stays synchronous. (getDelayedCount()was already async/correct.)- PUSH of a late dependent on an evicted
removeOnCompleteparent was wrongly rejected (src/infrastructure/server/handlers/core.ts): the TCP push dependency-existence gate checkedjobIndex/completedJobsbut notdepCompletions, so a child depending on a completedremoveOnCompleteparent was rejected with “Dependency job not found” even though the readiness path and dependency processor already honored it. The gate now also consultsdepCompletions(newQueueManager.getDepCompletions()accessor).
[2.8.14] - 2026-06-15
Section titled “[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)
Section titled “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/handleMaxStallsExceedednow callshard.releaseJobResources()before re-queue/DLQ, mirroring the stall-detection paths. Previously a queue withsetConcurrency(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 childdependsOna parent that returnedundefined(across a restart) or hadremoveOnComplete: truewas silently never run and dropped after 1h. Added a boundeddepCompletionsset for removeOnComplete parents and made dependency recovery recognizestate='completed'rows (not onlyjob_results). Fixes late-dependent ordering too. addBulk/ PUSHB ignoreddurable(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): withpoolSize > 1, dropping the connection that pulled a job re-queued a job a live worker was still running (double execution).releaseClientJobsnow 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 fromListWorkers/getForQueuewhile still consuming jobs. The pool now exposesonReconnect()and the worker re-registers on reconnect. (Visibility only, no data loss.) moveToWaitingChildrenstranded the job (queryOperations.ts,jobManagement.ts): a job moved to waiting-children was invisible togetJoband uncancellable.getJob/getJobByCustomId/cancelJobnow consultwaitingChildren.perQueueMetricsunbounded growth (queueManager.ts,cleanupTasks.ts): the per-queue metrics map grew one permanent entry per distinct queue name and was not freed byobliterate(). It is now LRU-bounded and freed byobliterate(); cumulative counters survive a transient drain.
[2.8.13] - 2026-06-15
Section titled “[2.8.13] - 2026-06-15”Fixed: explicit job.moveToFailed(err) now carries the stacktrace (#74 follow-up)
Section titled “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 throws 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 onFAIL(stack, TCP) /manager.fail(..., wireStack)(embedded), so the server persists them exactly like the throw path, visible viagetJob()and in DLQ entries.- Local
failedevent populated. The manual-move handler now setsjob.stacktrace(capped atjob.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
Section titled “[2.8.12] - 2026-06-15”Fixed: Worker no longer overshoots concurrency under bursty completions (#96)
Section titled “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-testsactiveJobs >= concurrencyimmediately beforestartJob(). There is noawaitbetween the check andstartJob()’sactiveJobs++, so the check is atomic with the increment and cannot overshoot. This single guard closes both the pull-await path and thesetImmediatebypass. - 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
Section titled “[2.8.11] - 2026-06-12”Fixed: job stacktrace persisted server-side (#74 follow-up)
Section titled “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”).
FAILnow 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 (newjobs.stacktracecolumn, migration 13), and rides into the DLQ entry when attempts are exhausted. - Readable everywhere:
queue.getJob()/getJobs()now return the realstacktrace(TCP + embedded, the proxy no longer hardcodesnull), DLQ entries expose it viaentry.job.stacktrace, and fetched jobs also reflectfailedReason(derived from the persisted timeline). - HTTP
POST /jobs/:id/failaccepts the same optionalstackarray. - Defensive caps along the wire: client sends at most 50 lines, server accepts
at most 100, the job’s own
stackTraceLimitis authoritative. - The worker
failedevent 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
Section titled “[2.8.10] - 2026-06-11”Fixed: CLI audit: top findings (2 critical + 4 high)
Section titled “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
bunqueuebinary entry point fell through tostartServer()for any unrecognized first argument, sobunqueue stast(typo),bunqueue version,bunqueue doctororbunqueue pingsilently started a full server (bound ports, created the default DB) instead of running the CLI. The server now boots only for a barebunqueue,start, or flag-led invocations; everything else routes to the CLI, and unknown commands exit 1 with an error. cron add --max-limit 0now 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
-tno longer stealspull/job waittimeouts.bunqueue pull q -t 5000used to sendAuth { token: "5000" };-tafterpull/jobis now passed through to the subcommand (long--tokenis global everywhere,-tbefore the command still works as token). webhook addevent 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-emittedjob.pushed/job.started. Valid events now:job.pushed,job.started,job.completed,job.failed,job.progress.bunqueue backuphonorsBUNQUEUE_DATA_PATH/BQ_DATA_PATH(canonical data-path priority) instead of onlyDATA_PATH/SQLITE_PATH.- Long-poll commands no longer die on the client’s own 30s timeout. For
PULL/WaitJob(only, on PUSHtimeoutis the job execution timeout and does not stretch the client wait) the CLI timeout scales with the command’stimeoutfield (+10s buffer), sopull --timeout 30000andjob wait --timeout 60000wait as requested. job waitthat times out now exits 1 with “Job not completed within timeout” instead of printing a greenOK(exit 0) indistinguishable from success.cron add --everyrejects non-positive intervals. A negative interval produced anextRunpermanently in the past, the cron fired on every scheduler tick, indefinitely.job wait --timeoutrejects negatives too.- Global value flags no longer swallow a following flag:
--token --json,-H --json,-p --jsonnow warn and keep--jsonworking (same guard--tls-caalready 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 startnow boots the SAME full server as a barebunqueue(shared bootstrap), S3 backup, cloud agent, stats interval, crash handlers and graceful drain were previously missing from thestartpath. Also fixesHTTP_SOCKET_PATHbeing shown in the banner but never applied on the bare entry. - Short
-h/-vare 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/--versionstay global;--helpafterpush/cronnow shows command-specific help. --separator: everything after--is opaque to the global parser (no more--json/-tstolen from values).- Attached short flags warn:
push q '{}' -p10silently dropped the priority and pushed anyway; now a warning points to the separated form. - Cron
maxLimitfixed at the domain level: 0/negative storenull(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. WaitJobtimeout capped server-side (0–600000 ms, likePULL), an unbounded wait could hold client and connection for days.- Formatters stop dropping operational data:
worker listshows status (stale workers are now visible), concurrency and job counters;webhook listshows enabled state, queue and delivery counters;cron listshows next run / max / timezone;statsshows uptime and push/pull rates;webhook addprints the webhookId (needed for remove);cron addprints the next run. job stateof a missing job exits 1 (“Job not found”) instead of printingState: unknownwith exit 0.
Remaining low audit findings are tracked for a follow-up release.
[2.8.9] - 2026-06-10
Section titled “[2.8.9] - 2026-06-10”Added: queue.forward() store-and-forward + prebuilt binaries
Section titled “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:
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:<queue>:<localId>, deduped server-side within the custom-id retention window (bounded LRU; remoteremoveOnCompleteevicts the entry, for strict exactly-once across long outages, dedupe downstream). - Preserves job name, data and priority; optional
durable: truefor per-job fsync server-side;forwarded/errorevents.
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
Section titled “[2.8.8] - 2026-06-10”Added: Native TLS (TCP + HTTP) and MQTT bridge example
Section titled “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, orTLS_CERT_FILE/TLS_KEY_FILEenv vars, orserver.tlsCertFile/tlsKeyFileinbunqueue.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.tlsonQueue/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 <file>,--tls-no-verifyglobal 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.
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
Section titled “[2.8.7] - 2026-06-07”Fixed: API audit: HTTP routes and TCP commands now honor every documented parameter (#95 + full audit)
Section titled “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=<state>ignored the filter, onlystatewas read, so it returned the whole queue regardless of the requested state (#95). Now acceptsstatus,state, andstates, each repeatable and comma-separated.POST /jobs/:id/ackandPOST /jobs/:id/faildropped thetoken(lock ownership) field.PUT /jobs/:id/prioritydroppedlifo(tie-break ordering).POST /cronsdroppedimmediately,skipIfNoWorker,preventOverlap, andjobOptions.- 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 singulardurationbut the handler reads a per-iddurations[]array, and readextendedfrom a response that returnscount, batch lock renewal silently kept the old TTL.RetryDlq: the client sentidbut the handler readsjobId, so retrying a single DLQ entry retried the entire DLQ.PromoteJobs: the client readpromotedfrom a response that returnscount, so it always reported 0 promoted jobs.Clean: the client senttypebut the handler readsstate, so the state filter was ignored.UnrecoverableErrorover TCP: theunrecoverableflag on FAIL was dropped server-side, so unrecoverable jobs were retried per theirmaxAttemptsinstead of failing immediately (worked only in embedded mode).- Worker
lockDurationwas never sent aslockTtlon PULL, so the server always used its 30s default regardless of the configured value. GetLogsignored thestart/endpagination parameters the client already sent.- Scheduled (cron) jobs dropped
priority.
Counts consistency
getJobCounts/getStatsundercountedwaiting-children: jobs blocked ondependsOn(waitingDeps) report statewaiting-childrenand appear ingetJobs({ 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/SetDlqConfigcoerce numeric strings and drop non-numeric garbage (so the manager keeps its default) , previously a stringstallIntervalreached numeric comparisons asNaNand silently disabled stall detection for the queue.RateLimit/SetConcurrencynow reject a non-finitelimitinstead of storingNaN. PUT /queues/:q/concurrencynow accepts the naturalconcurrencyfield as well aslimit(sending{ "concurrency": N }previously silently did nothing).GET /queues/:q/dlqsupports optional?limit/?offsetpagination and returnstotal.- The
Cronresponse now echoes the jobpriority; a singlePULLno longer sends a redundant batchcount.
[2.8.6] - 2026-06-07
Section titled “[2.8.6] - 2026-06-07”Fixed: half-open TCP connections now recover off command timeouts, not just the ping (#94)
Section titled “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.
maxCommandTimeoutsconsecutive in-flight command timeouts with no intervening success (default 3,0disables) 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 viaconnection.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 fullcommandTimeouton a corpse. They are now rejected at once withConnection lost.SO_KEEPALIVEenabled on client sockets so the OS surfaces a dead peer on its own rather than waiting outtcp_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
Section titled “[2.8.5] - 2026-06-05”Fixed: write buffer no longer drops unrelated jobs on a duplicate id (data loss)
Section titled “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
jobIdin two different queues sharing one database, or - reusing a custom
jobIdafter its job completed:markCompletedUPDATEs 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
jobIdnow evicts the old completed record (row, result, in-memory state) so the new job starts fresh aswaitinginstead of colliding (andgetJobStateno longer returnscompletedfor 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
Section titled “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
Section titled “[2.8.4] - 2026-06-05”Fixed: getJobCounts() / per-state lists now agree with the real state (#92)
Section titled “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
dlqtable and itsjobsrow is removed, sogetJobs({ state: 'failed' })/getFailedAsync()ranSELECT … FROM jobs WHERE state='failed'and came back empty, even thoughfailedcounted it,getJobState()returned'failed', andgetJob(id)found it (standalone server, and embedded with adataPath). The storage path now also reads the DLQ for'failed', mirroring the in-memory path. The unfilteredgetJobs()likewise includes failed jobs. -
pause()double-counted. A single ready job was reported under bothwaitingandpaused, whilegetJobs({ state: 'paused' })returned[]. Now follows BullMQ semantics: a paused queue reports its ready jobs (waiting and prioritized) underpaused, withwaiting: 0/prioritized: 0, and lists them viagetJobs({ state: 'paused' }). A job is never counted in two buckets at once. Applied consistently across the client SDK, the TCPGetJobCountshandler, and the dashboard detail endpoint (which also gains apausedjob 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)
Section titled “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
nodefromengines(nowbun >= 1.3.9only). - 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
Section titled “[2.8.3] - 2026-06-03”Fixed: expose ./package.json in exports
Section titled “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
Section titled “[2.8.2] - 2026-06-02”Stop shipping source maps: another −34% off the install
Section titled “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
Section titled “[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)
Section titled “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)
Section titled “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
Section titled “Migration”-
Queue users (
bunqueue/client,Queue/Worker/Workflow,bunqueue/queue,bunqueue/workflow), no action required. The public bundles contain zero SDK/zodcode; your install simply gets smaller. -
MCP users (
bunqueue-mcporimport 'bunqueue/mcp'), install the SDK once in the environment where the server runs:bun add @modelcontextprotocol/sdkbunx --package=bunqueue bunqueue-mcpdoes 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)
Section titled “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
Section titled “Implementation notes”src/mcp/index.tsis now a thin launcher; the server implementation moved tosrc/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 indevDependenciesfor build/test).zodremoved fromdependencies(it ships with the SDK; pinned indevDependenciesfor builds).bunremoved frompeerDependencies;engines.bunaligned to>=1.3.9. Declaring a runtime as a peer triggers spurious resolution warnings under npm/pnpm/yarn,enginesis the correct field.webhookToolsswitchedz.url()→z.string().url()for compatibility across the SDK’s acceptedzodrange (^3.25 || ^4.0).
Verification
Section titled “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)
for reporting the footprint and proposing both the MCP split and the
peerDependencies → engines change.
[2.7.22] - 2026-06-02
Section titled “[2.7.22] - 2026-06-02”Fixed (CI: broken transitive publish of typescript-eslint@8.60.1)
Section titled “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-eslintto exact8.56.1(dropped the^caret) inpackage.jsonso 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
Section titled “[2.7.21] - 2026-06-02”Fixed (docs: bunx bunqueue-mcp 404, #91)
Section titled “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(orbun add -g bunqueue) beforebunx bunqueue-mcp, and a caution box explains the 404. - All JSON MCP configs switched to
args: ["--package=bunqueue", "bunqueue-mcp"]: copy-paste safe:bunxresolves the bundled binary straight from thebunqueuepackage with no separate install. The skill configs use the same form;npxwas replaced withbunx(the MCP entry’s shebang is#!/usr/bin/env bun). - Removed the misleading
bunx bunqueue-mcp --helpfrom troubleshooting, the MCP entry doesn’t parse CLI args (it starts the stdio server immediately). - Repo
.mcp.jsonnow runs the local source (bun run src/mcp/index.ts) instead of fetching from npm.
[2.7.20] - 2026-05-31
Section titled “[2.7.20] - 2026-05-31”Fixed (live full-feature E2E audit: 3 bugs surfaced by hands-on testing)
Section titled “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 viagetJobState/getJob/getWaiting/getJobsand would reload on restart.drainQueuenow deletes each drained job’s row (via the samesafeDeleteJobpathclean/obliterateuse, which also clears any pending write-buffer entry). Only waiting/delayed/prioritized jobs are drained (active jobs untouched). (application/operations/queueControl.ts)- Workflow
waitFortimeout ran saga compensation twice: on awaitFortimeout,runWaitFor()compensated and then threw a plainError, whichprocessStep’s catch re-compensated. It now throws theWaitForSignalErrorsentinel (and emitsworkflow:failedonce) so compensation runs exactly once. The signal-success path, normal step-failure path andforEachcompensation are unchanged. (client/workflow/executor.ts) - Auto-batch
add()swallowed server rejections: when the server rejected aPUSHB(e.g. auth failure),addBulkreturned[]and the auto-batcher resolved callers withundefinedinstead of throwing, so a batchedqueue.add()silently appeared to succeed (the server correctly persisted nothing, this was an error-propagation defect, not an auth bypass).addBulknow throws on!response.ok(mirroring the single-PUSHpath), so all batched callers reject; an OK-but-empty response still returns[]. (client/queue/operations/add.ts)
[2.7.19] - 2026-05-31
Section titled “[2.7.19] - 2026-05-31”Fixed (stability audit: 13 confirmed failure-path bugs, each with a reproducing test)
Section titled “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_FIELDSwas applied only to the event stream, never to periodic snapshots, so rawjob.dataand DLQjobData(potential PII/secrets) were sent to the dashboard. Redaction (andincludeJobDatagating) is now threaded through the snapshot path via a sharedredacthelper. (cloud/snapshotHelpers.ts,snapshotCollector.ts,cloudAgent.ts, newcloud/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
dependsOnblob 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-proofSymboland 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
processingand 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 bymaxFrameSize. 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 anddrain()was a no-op. A per-socket write queue now buffers unwritten bytes (order-preserving), flushes ondrain(), and caps atTCP_MAX_WRITE_QUEUE_BYTES(default 64MB, drops the connection past the cap). (server/tcp.ts, newserver/socketWriteQueue.ts) - TCP client hung on a malformed frame (pipelining): only the legacy
currentCommandwas 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/ignoredChildrenFailureswere never cleared on normal parent completion or inshutdown()(only onobliterate). They are now released when the parent reaches a terminal state and cleared on shutdown. (application/queueManager.ts) forEachsaga compensation lost iteration context: compensate handlers couldn’t tell which item they were rolling back (__item/__indexweren’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:
lastFiredAtwasn’t cleared onremove()/upsert, so a same-named cron hit stale overlap detection. It is now cleared on remove/upsert. (scheduler/cronScheduler.ts) - Interval cron drift:
repeatEverynextRunwas 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
maxEntriesafter restart:restoreEntry()skipped the evictionadd()performs; it now enforcesmaxEntries(oldest-first) on recovery. (domain/queue/dlqShard.ts)
[2.7.18] - 2026-05-31
Section titled “[2.7.18] - 2026-05-31”Fixed (option-forwarding audit, follow-ups to #88)
Section titled “Fixed (option-forwarding audit, follow-ups to #88)”getJobsAsync()(andgetWaitingAsync/getDelayedAsync/getActiveAsync/getCompletedAsync/getFailedAsync) droppedjob.optsover TCP: listed jobs returnedopts: {}, sojob.opts.attempts/timeout/etc. wereundefined, whilegetJob(id).optswas correct. The server already sends the full job; the client now reflects the completeoptsviametaFromJob. This closes the slim-optslimitation noted in 2.7.17.- Returned Job hardcoded
deduplicationId/parentKey/parent/repeatJobKey:createJobProxy/createSimpleJobset these toundefinedeven when known at call time, diverging from embedded mode. They are now derived from the requested options (sharedreflectFields), matchingbuildJobProperties. FlowProducersilently dropped extended job options: flow nodes ignoredlifo,deduplication,durable,stallTimeout,stackTraceLimit,keepLogs,sizeLimit,repeat,timestampanddebouncein both embedded and TCP modes (durable: truebeing ignored meant a critical flow job used buffered writes instead of immediate persistence).flowPushnow forwards the full option set, mirroringQueue.add.job.toJSON()/asJSON()hardcodedopts: {}anddelay: 0: the BullMQ-compatible serializers on a TCP/bulk-created Job lost the reflected options. They now reflectopts,delayandparentKey.changePriority({ priority, lifo })silently droppedlifo: the option was accepted by the type but never applied (the engine had no way to honor it).lifois 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, andFlowProducerjob nodes.
Changed
Section titled “Changed”JobOptions.removeOnComplete/removeOnFailnarrowed toboolean: the previously documentednumber | 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-PUSHpath coerces for embedded/TCP parity, and the server hardensparseCoreOptionswithBoolean(). (Worker-levelremoveOnComplete/removeOnFaildefaults are unaffected.)
[2.7.17] - 2026-05-30
Section titled “[2.7.17] - 2026-05-30”- Created job has wrong priority / options not reflected in TCP mode (#88):
await queue.add(name, data, { priority: 10 })returnedjob.priority === 0over TCP. The Job object returned byadd()/addBulk()(andgetJob()/getJobs()) is built client-side bycreateJobProxy/createSimpleJob, which hardcodedpriority: 0,delay: 0,opts: {}. These now reflect the requested/stored options (priority,delay,opts). Embeddedadd()was already correct (it usestoPublicJob). - TCP
add()silently dropped job options: the single-job TCPPUSHpath forwarded only a subset of options, sodeduplication,ttl,tags,groupId,lifo,keepLogs,sizeLimit,stackTraceLimit,debounce,dependsOn,failParentOnFailure,removeDependencyOnFailure,continueParentOnFailure,ignoreDependencyOnFailureandtimestampwere ignored when adding a single job over TCP. ThePUSHcommand and its handler now carry and apply the full option set, matching embedded mode and bulk add.addBulkforwarding gaps (removeOnComplete/removeOnFail, parent, dedup, tags, groupId, dependsOn) were closed too.
Changed
Section titled “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.
getJobsAsync()returns a slimopts({}) for listed jobs, whereasgetJob()returns the fullopts. The reflecteddelaytracks 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
Section titled “[2.7.16] - 2026-05-29”- MCP returns inconsistent numbers across monitoring tools (#87): In TCP mode the MCP
TcpBackendparsed 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_countsnow readsres.counts.*(was reading top-level → always0);bunqueue_list_workersreadsres.data.workerswith the correct field names (processedJobs/failedJobs/lastSeen) and no longer returns[]for a registered worker;bunqueue_get_jobsmaps the tool’sstart/endto the protocol’soffset/limitso pagination works (previouslystartwas ignored and the page defaulted to 100 instead of the requested size);bunqueue_get_per_queue_statsnow uses theDashboardQueuescommand for a real per-queue breakdown ({waiting, prioritized, delayed, active, dlq}) instead of globalMetrics, matching embedded mode. TheDashboardQueueshandler now also forwardsprioritized.
bunqueue_get_counts_per_prioritycounts 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
Section titled “[2.7.15] - 2026-05-26”- Cron/scheduler jobs ignored job options (#86): Jobs spawned by
upsertJobScheduler/cron always usedJOB_DEFAULTS(maxAttempts: 3,removeOnFail: false), ignoring both the scheduler job templateoptsand the QueuedefaultJobOptions. A scheduler withattempts: 1, removeOnFail: truestill retried 3× and landed failed jobs in the DLQ. Cron definitions now carry ajobOptionsfield (maxAttempts,backoff,timeout,delay,stallTimeout,removeOnComplete,removeOnFail) thatfireCronJobforwards into each spawned job. The client merges QueuedefaultJobOptions(base) with per-scheduler templateopts(override), mappingattempts→maxAttempts. Persisted via newjob_optionscolumn (schema migration 12); old rows load asnulland fall back to defaults.
- For cron jobs,
removeOnComplete/removeOnFailhonor only the boolean form. The numeric/KeepJobsvariants accepted byqueue.add()are not applied to scheduler-spawned jobs and fall back tofalse. - A per-job
delayset in scheduler options stacks on top of the cron fire time (the spawned job is delayeddelayms after each scheduled fire).
[2.7.14] - 2026-05-15
Section titled “[2.7.14] - 2026-05-15”Fixed (CLI audit, 8 bugs)
Section titled “Fixed (CLI audit, 8 bugs)”worker registervia CLI silently expires: Server auto-unregisters workers when their TCP connection closes; one-shot CLI commands disconnect immediately, soworker listright afterworker registershowed nothing. CLI now prints a stderr warning explaining transience and pointing users to the SDKWorkerclass for persistence.pulldisplayedState: unknown: Server-sideJobdoesn’t carry an explicitstatefield (state lives injobIndex), so the PULL response omitted it.src/cli/output.tsnow 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 displayunknownrather than a confident guess.job progressandjob delayerrors conflated “not found” with “not active”: Both handlers (management.tsProgress,advanced.tsMoveToDelayed) returned the literal stringJob not found or not active. They now querygetJobStateon failure and emit eitherJob not foundorJob is not active (current state: X), so operators can act on the distinction.- Client ignored env vars
TCP_PORT/HOST: Server readsTCP_PORT,HTTP_PORT,HOST; CLI client only honored--port/--host. Asymmetric. Client now readsTCP_PORT(primary, matches server) plusBUNQUEUE_TCP_PORT/BQ_TCP_PORTaliases forHOSTtoo. Priority: explicit CLI flag > env > default. queue cleanoutput saidCreated 0 jobs: Batch-id formatter used a single “Created” verb for all responses withidsarrays. Now context-aware:push→Created,queue clean→Cleaned,queue drain→Drained,dlq retry→Retried,dlq purge→Purged. Falls back toAffectedfor unknown contexts.job resultprinted literalResult: undefined: When a job’s result isundefined/null(job not completed orremoveOnComplete: true), CLI now showsNo result available (job not completed or result was removed)instead of stringifying undefined.- Short flags
-h/-vtriggered server start instead of help/version: Global parser treated unknown short args as server flags.-hnow aliases--help,-valiases--version(server’s existing-H/-p/-tshort flags unchanged).
- 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 toawaitafterhandleGetProgressbecame async (needed for state disambiguation).
Internal
Section titled “Internal”formatOutputandformatSuccess(src/cli/output.ts) now accept an optionalsubcommandarg so batch-id responses can pick the right verb.handleGetProgress(src/infrastructure/server/handlers/management.ts) changed signature from syncResponseto asyncPromise<Response>to support disambiguation viagetJobState.
[2.7.13] - 2026-05-15
Section titled “[2.7.13] - 2026-05-15”- WriteBuffer silent data loss when retries exhausted:
SqliteStoragepreviously constructedWriteBufferwithout anonCriticalErrorcallback, so jobs dropped aftermaxRetries(10) vanished with no recovery path (the retry-exhaustion branch now lives atwriteBuffer.ts:95-105).SqliteStoragenow 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 useronCriticalLossconfig callback. New API:storage.getCriticalLosses()/storage.clearCriticalLosses(). AsyncLock/RWLockdouble-release broke mutual exclusion:guard.release()had no idempotency check; a stale double-release could clobber the next owner’slocked=trueflag 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,RWLockread,RWLockwrite) now track a per-guardreleasedflag and short-circuit subsequent calls.- State-transition writes raced with buffered INSERTs:
markActive/markCompleted/markFailedran synchronously while the correspondinginsertJobwas still in the 10ms-batchedWriteBuffer. TheUPDATEmatched 0 rows silently, then the bufferedINSERTlater wrote with the stale insert-time state (waiting/delayed), clobbering intent. AddedWriteBuffer.hasPending(jobId)and a privateSqliteStorage.flushIfBuffered(jobId)helper invoked at the top of every state-mutating method so the row exists on disk before theUPDATEruns. 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
clientJobsMap entries on retry exhaustion:tcp.tsclose handler calledreleaseClientJobsWithRetry(3 attempts with exponential backoff); on persistent lock contention only logged, leaving (a) theclientJobsMap entry uncleared (leaks across flapping reconnects) and (b) jobs stuck inactivestate until the full stall timeout (~30s).clientTracking.releaseClientJobsnow wraps the locked release block intry/finallyso the Map entry is always deleted. NewforceReleaseClientJobs(clientId)performs a lock-free best-effort cleanup: clearsclientJobs, drops orphanedjobLockstokens, and expires bothlastHeartbeat=0andstartedAt=0so the stall detector recovers within ~2 ticks (~10s with defaults).tcp.tsclose handler invokes it in the catch branch as a last-resort fallback. SandboxedWorkerCannot find moduleflake on macOS: Tests usingBun.writeto create processor files (no fsync) followed byWorkerspawn could fail withModuleNotFoundbecause the file wasn’t yet visible to the fresh Worker process.createWrapperScriptinsrc/client/sandboxed/wrapper.tsnow polls for processor visibility (up to 20×5ms), normalizes the path (removesTMPDIR-trailing-slash double slashes), and resolves symlinks (/var→/private/varon macOS) so the wrapper’sawait import(...)sees the same path Bun’s module loader uses.Client closedunhandled rejection on intentional TCP shutdown:TcpClient.close()callscommands.rejectAll(new Error('Client closed')), which rejected any in-flight Promises. Callers without a.catchin 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.tsrejectAllnow attaches a silent.catchon each command’s tracked Promise reference (newPendingCommand.promisefield) before rejecting. A one-shotprocess.on('unhandledRejection')filter inTcpClient.close()catches the rare chained-Promise leak whose.catchlives further down the chain.
- 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, includingjobLocksdrop +startedAt=0invariants)
Internal
Section titled “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 aSet<JobId>mirror.SqliteStorageconstructor accepts newonCriticalLoss?: (jobs, error, attempts) => voidcallback.QueueManager.forceReleaseClientJobs(clientId): number, synchronous, returns count of jobs whose state was reset.PendingCommand.promise?: Promise<...>, optional reference to the caller-visible Promise sorejectAllcan attach silent.catch.
[2.7.12] - 2026-05-11
Section titled “[2.7.12] - 2026-05-11”Internal
Section titled “Internal”- Remove 63 unnecessary
as Typeassertions acrosssrc/flagged by@typescript-eslint/no-unnecessary-type-assertionon CI’s stricter@types/bun@1.3.13. Pure type-level cleanup, no runtime impact. - Refactor
src/cli/output.tsstr()to narrowunknownvia explicittypeofbranches and a{ toString(): string }interface cast, avoiding bothno-unnecessary-type-assertionandno-base-to-stringrule conflicts.
[2.7.11] - 2026-05-11
Section titled “[2.7.11] - 2026-05-11”defineConfigcaused “Failed to listen at 0.0.0.0” when used in config file (Issue #85, reported by @timnew),src/main.tsre-executes its top-level dispatch on every import. Runningbunqueue start -c typed.tsstarted the server, thenloadConfigFile()imported the user config which importsdefineConfigfrom'bunqueue'→ resolves todist/main.js→ top-level code seesargv[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 inif (import.meta.main)so importing the package entry (fordefineConfigor other re-exports) has no side effect. Behavior whensrc/main.tsis the actual entry (e.g.bun run src/main.ts, compiled binary) is unchanged.
- New regression test
test/issue-85-config-import-side-effect.test.ts, spawns a subprocess that importssrc/main.tswithprocess.argvemulatingbunqueue start, asserts no server banner/bind logs.
[2.7.10] - 2026-04-20
Section titled “[2.7.10] - 2026-04-20”clean()left orphan rows injob_resultstable (Issue #84, follow-up from @jdorner),storage.deleteJob()executed onlyDELETE FROM jobs, so cleaned completed jobs’ result rows persisted forever injob_results.deleteJob()now runs bothDELETE FROM jobsandDELETE FROM job_results WHERE job_id = ?inside a singledb.transaction(...)block, atomically cascading the removal. DLQ is intentionally not cascaded here:moveFailedJobToDlq()relies onsaveDlqEntry+deleteJobpreserving the DLQ row. Callers that clean DLQ (e.g.cleanFailed) explicitly calldeleteDlqEntrybeforehand.
deleteJobResultprepared statement insrc/infrastructure/persistence/statements.ts.
- 2 regression tests in
test/client-queue-operations.test.ts: clean(‘completed’) leaves no orphanjob_resultsrows; clean(‘failed’) leaves no orphan rows in jobs/dlq/job_results. - Updated
test/sqlite-serializer.test.tsstatement count (13 → 14).
[2.7.9] - 2026-04-20
Section titled “[2.7.9] - 2026-04-20”clean()/cleanAsync()returned array of empty strings (Issue #84, follow-up from @jdorner), Previously returnednew Array(count).fill(''), so the result length was correct but the IDs were empty. Now returns the actualJobId[]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 repopulatejobIndex/completedJobs/completedJobsDatafor completed jobs in SQLite, socleanAsync('completed')after a restart found nothing to clean andstats.completedunder-reported. Added Phase 3 recovery: loads up tomaxCompletedJobs(default 50k) jobs ordered bycompleted_at DESC, populates in-memory indexes. Does not touchcustomIdMap(preserves pending-job dedup).
- SQLite migration 11:
idx_jobs_completed_orderindex on(completed_at DESC) WHERE state = 'completed'for O(log n) recovery ordering.
Protocol
Section titled “Protocol”CountResponsenow carries an optionalids?: string[]field, populated by theCleanhandler so TCP clients receive the removed job IDs (previously only the count).
- 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.tspersistence-under-load expectation from 100 → 200 (completed jobs now survive restart, so cumulative total is correct).
[2.7.8] - 2026-04-20
Section titled “[2.7.8] - 2026-04-20”cleanAsync()silently returned[]forcompleted/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 supportcompleted,failed, and waiting-like states (wait/waiting/delayed/prioritized/paused), with per-state helpers (cleanWaitingLike,cleanCompleted,cleanFailed) that remove entries fromjobIndex,completedJobs/completedJobsData, DLQ,jobResults/jobLogs, and SQLite (jobs+dlqtables).'wait'is now normalized to'waiting'(BullMQ alias).cleanAsync()SQLite write failures corrupted state:storage.deleteJob/deleteDlqEntryinside 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
Section titled “Changed”cleanAsync('active')is intentionally unsupported: cleaning in-flight jobs races with the worker’s ack path and leaks concurrency/uniqueKey/groupId slots. Usefail(jobId)orcancelJob(jobId)to terminate an active job safely.
- 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
Section titled “[2.7.7] - 2026-04-19”- Wrong job state after server restart (Issue #83),
getJobState/getJob/job.getStatereturnedunknown/nullfor completed, failed, and DLQ jobs after restart becausejobIndexwas not repopulated for completed/DLQ jobs during recovery. NowgetJobandgetJobStatefall back to SQLite whenjobIndexhas no entry, correctly resolvingcompleted/failed/prioritized/delayed/waitingstates post-restart.recover()also populatesjobIndexfor restored DLQ entries. - Stale
jobsrow retained when job enters DLQ:ack.ts(MaxAttemptsExceeded),stallDetection.ts,queueManager.failParent, andjobManagement.moveToFailednow callstorage.deleteJob(jobId)aftersaveDlqEntry. Without this, recovery would re-queue DLQ’d jobs as stalled actives on restart (legacy orphan rows also cleaned up vialoadDlqJobIdsguard in Phase 1 recovery). - Write-buffer/delete race in SQLite persistence: When a job was inserted through the 10ms-batched
writeBufferthen 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. AddedWriteBuffer.removePending(jobId)invoked fromdeleteJobto cancel pending inserts before SQL DELETE. - DLQ-retried jobs did not survive restart:
retryDlqJob,retryDlqJobs(bulk),retryDlqByFilter, andprocessAutoRetrynow re-insert the job into SQLite viainsertJob(job, true)after pushing to the in-memory queue. Required because the jobs row is deleted when a job enters DLQ.
- New
test/issue-83-jobstate-after-restart.test.ts(4 tests: completed-state post-restart,jobProxy.getStatepost-restart, failed/DLQ state post-restart, retryDlq-ed job persists across restart).
[2.7.6] - 2026-04-17
Section titled “[2.7.6] - 2026-04-17”- 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 toretryDlq, 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.moveToWaitsemantic divergence between embedded and TCP: Embedded calledmoveActiveToWait(active→waiting) while the TCP server handler calledpromote()(delayed→waiting). Same API, opposite outcomes. Server handler now state-dispatches to match embedded;jobProxyembedded 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
ModuleNotFoundon concurrent spawn (macOS), Two root causes: (1)$TMPDIRtrailing slash produced//in wrapper path; (2) concurrentnew Worker()raced for Bun’s bundler cache. Fixed by (1)path.joinnormalization +fsyncon write + existence poll that throws on miss, and (2) serializing the first worker spawn so the bundle is cached before siblings load. res.oktruthy read onunknown: 4 sites (extendLockhandlers,moveToWait) used looseres.ok ? x : y; harmonized to=== true.jobProxy.extendLockdropped the user-provided token in TCP mode: Server sawnulland could reject or no-op depending onjobLocksownership. Token now passed through.
- 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.tsproving every previously-unwired method is now reachable. - Post-condition assertions added for
remove()inside processor.
[2.7.5] - 2026-04-16
Section titled “[2.7.5] - 2026-04-16”job.moveToFailed()inside processor was a no-op (Issue #82), Callingjob.moveToFailed()inside a worker processor silently did nothing because move method callbacks were not wired tocreatePublicJob. The worker then auto-ACKed the job, marking it as completed instead of failed. NowmoveToFailed()andmoveToCompleted()work correctly inside processors: they send the appropriate command and prevent the auto-ACK from overriding the state.
Changed
Section titled “Changed”- Extracted handler factories from
processor.tsinto newsrc/client/worker/processorHandlers.tsfor single-responsibility compliance.
- 3 new issue #82 reproduction tests (
test/issue-82-moveToFailed.test.ts)
[2.7.4] - 2026-04-13
Section titled “[2.7.4] - 2026-04-13”- 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). ReturnsRecoverResultwith counts. - Type-safe workflow steps:
Workflow<TInput>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 withoutascasts. Works with.parallel(),.map(),.forEach(),.subWorkflow(). Fully backward compatible. - New
src/client/workflow/compensator.ts, ExtractedWaitForSignalErrorandrunCompensation()from executor. - New
src/client/workflow/recovery.ts, Recovery logic withRecoverDepsinterface andrecoverExecutions(). - New
WorkflowStore.listRecoverable()method, Queries SQLite for executions in recoverable states. - Exported
RecoverResult,TypedStepHandler,TypedCompensateHandlerfrombunqueue/workflow.
Documentation
Section titled “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.
- 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
Section titled “[2.7.3] - 2026-04-12”- 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
AggregateErrorinstead 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 emitsstep:startedandstep:completedevents, making map nodes observable like all other node types.
- Added 24 workflow engine issue reproduction tests (
test/workflow-issues.test.ts)
[2.7.2] - 2026-04-10
Section titled “[2.7.2] - 2026-04-10”- Loop control flow: New
.doUntil(condition, builder, opts?)and.doWhile(condition, builder, opts?)DSL methods for conditional iteration.doUntilruns steps then checks condition (do…until),doWhilechecks condition first (while…do). Both supportmaxIterationssafety 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 receivesctx.steps.__itemandctx.steps.__index. SupportsmaxIterations(default: 1000). - Map transform: New
.map(name, transformFn)for synchronous data transforms between steps. No retry, no timeout, pure computation node. - Schema validation: New
inputSchemaandoutputSchemaoptions 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
Section titled “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
- 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(addedretry: 1to failing step) - All 5,305 existing tests continue to pass
[2.7.1] - 2026-04-10
Section titled “[2.7.1] - 2026-04-10”- Step retry with exponential backoff: Steps now retry automatically with configurable
retrycount. Backoff usesmin(500ms × 2^attempt + jitter, 30s). Attempt count tracked inexec.steps['name'].attempts. - Parallel steps: New
.parallel()DSL method runs multiple steps concurrently viaPromise.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 inctx.steps['sub:<name>']. - Observability (typed events): New
WorkflowEmitterwith 11 event types:workflow:started/completed/failed/waiting/compensating,step:started/completed/failed/retry,signal:received/timeout. Subscribe viaengine.on(),engine.onAny(), oronEventconstructor option. - Cleanup & archival:
engine.cleanup(maxAgeMs, states?)deletes old executions.engine.archive(maxAgeMs, states?)moves them toworkflow_executions_archivetable (transactional, up to 1000 per call).engine.getArchivedCount()returns archive size.
Changed
Section titled “Changed”- Refactored
executor.ts(362→273 lines): extractedbuildContext(),findStepDef(),executeStepWithRetry(),executeParallelSteps(),executeSubWorkflow()to newrunner.ts - New
emitter.ts(115 lines) for event system processStep()now allows'waiting'state (for signal timeout re-checks)
Documentation
Section titled “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
- 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
Section titled “[2.7.0] - 2026-04-10”- 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
compensatehandlers 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_executionstable; 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
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 }); - Fluent DSL: Chain
Documentation
Section titled “Documentation”- New page: Workflow Engine guide 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
- 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
Section titled “[2.6.116] - 2026-04-09”- 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), allowingevery()/cron()to create duplicate jobs. The fix checksprocessingShardsandwaitingDepsbefore considering a queue “empty”. Fixes #80.
[2.6.115] - 2026-04-08
Section titled “[2.6.115] - 2026-04-08”prefixKey, namespace isolation forQueueandWorker: 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.namestill reports the logical name; the prefix is applied internally to the server-side key. Backward compatible, withoutprefixKey, behavior is identical. Resolves the cronnamePRIMARY KEY collision in #77. Example:See the Namespace Isolation guide.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 queuenew Worker('emails', processor, { prefixKey: 'dev:' });
[2.6.114] - 2026-04-07
Section titled “[2.6.114] - 2026-04-07”- Worker
'ready'event never fires with chained listener:Worker.run()was emitting'ready'synchronously inside the constructor (whenautorun: true, the default), so listeners attached via the chained patternnew Worker(...).on('ready', ...)were registered too late and missed the event. The emit is now deferred viaqueueMicrotask, so listeners attached synchronously after construction still receive'ready'. Fixes #76.
[2.6.113] - 2026-04-03
Section titled “[2.6.113] - 2026-04-03”- Cron job with
preventOverlapfires 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
Section titled “[2.6.112] - 2026-04-03”bunqueue versioncommand: Shows client version and server version (if reachable), with mismatch detection warning.bunqueue doctorcommand: Run diagnostics: checks connectivity, version match, server health, queue state, and memory usage. Useful for debugging deployment issues.
[2.6.111] - 2026-04-03
Section titled “[2.6.111] - 2026-04-03”bunqueue statsshowing zeros for waiting/active: TCP Stats command was returning fields namedqueued/processingwhile the CLI expectedwaiting/active. Aligned TCP response to use standard field names (waiting,active,failed) consistent with HTTP/healthendpoint.
[2.6.110] - 2026-04-03
Section titled “[2.6.110] - 2026-04-03”- Stacktrace now included in
failedworker event:job.stacktracewas alwaysnullwhen a job threw an error. Now correctly populated with the error’s stack trace lines, respectingstackTraceLimit(default: 10). Fixes #74.
[2.6.109] - 2026-04-03
Section titled “[2.6.109] - 2026-04-03”Changed
Section titled “Changed”- Cloud instance ID required:
BUNQUEUE_CLOUD_INSTANCE_IDenv 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
cloudsection only exposesurl,apiKey, andinstanceId. All other cloud settings are internal (env vars only). - Default changes:
remoteCommandsdefaults totrue(wasfalse),includeJobDatadefaults totrue(wasfalse). - 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
Section titled “[2.6.108] - 2026-04-02”bunqueue.config.ts, Global configuration file: Centralize all server configuration in a single typed file, similar tovite.config.tsordrizzle.config.ts. Auto-discovered from project root, supportsbunqueue.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 bothbunqueueandbunqueue/clientfor full TypeScript IntelliSense.--config/-cCLI flag:bunqueue start --config ./custom.config.tsto specify an explicit config file path.CloudAgent.createFromConfig(): Static factory method that accepts a pre-resolvedCloudConfig, 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.tsas the recommended configuration approach.
[2.6.107] - 2026-04-02
Section titled “[2.6.107] - 2026-04-02”- Fix contextFactory test: updated
getLockContexttest to reflect thestoragefield added in v2.6.103 for cron job cleanup on disconnect (#73).
[2.6.106] - 2026-04-02
Section titled “[2.6.106] - 2026-04-02”- 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,
upsertJobSchedulerwithpreventOverlapremoves 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
Section titled “[2.6.105] - 2026-04-02”skipIfNoWorkernow 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,skipIfNoWorkerwould find the stale worker and push cron jobs. Now only workers with a recent heartbeat (withinWORKER_TIMEOUT_MS, default 30s) are counted (fixes #73).
[2.6.104] - 2026-04-02
Section titled “[2.6.104] - 2026-04-02”- Stall detector no longer re-queues cron jobs: the stall detection system (both retry and DLQ paths) now discards cron jobs with
preventOverlapinstead 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
Section titled “[2.6.103] - 2026-04-02”- Cron jobs no longer fire immediately on client reconnect: when a TCP/WebSocket client disconnected while processing a cron job with
preventOverlap,releaseClientJobswould 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 withpreventOverlap(uniqueKeycron:*) are discarded on disconnect, the cron scheduler re-creates them at the next scheduled tick (fixes #73).
[2.6.102] - 2026-04-02
Section titled “[2.6.102] - 2026-04-02”- Event subscription leak on HTTP server shutdown:
queueManager.subscribe()returned an unsubscribe function that was discarded. Onstop(), the subscription remained active, preventing garbage collection. Now properly unsubscribed during shutdown.
[2.6.101] - 2026-04-02
Section titled “[2.6.101] - 2026-04-02”- 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
Section titled “[2.6.100] - 2026-04-02”- 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
skipIfNoWorkerto 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
cancelhandler now releases owned jobs back to the queue on disconnect, matching the behavior of TCP and WebSocket handlers.
[2.6.99] - 2026-04-02
Section titled “[2.6.99] - 2026-04-02”- 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
Section titled “[2.6.98] - 2026-04-01”- Cron overlap prevention: added
preventOverlapoption (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
Section titled “[2.6.97] - 2026-04-01”- Cron jobs no longer fire immediately on restart:
skipMissedOnRestartnow defaults totrue. Past-due crons recalculatenextRunto the next future occurrence instead of executing immediately (fixes #73). UseskipMissedOnRestart: falseto opt in to catch-up behavior.
[2.6.96] - 2026-04-01
Section titled “[2.6.96] - 2026-04-01”- Job state race condition in TCP mode:
getJobState()inside thecompletedevent callback now correctly returnscompletedinstead ofactive(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
Section titled “[2.6.95] - 2026-03-31”- AI-native completeness: three additions for perfect Claude Code integration:
.mcp.jsonat root, auto-discovery of bunqueue MCP server, no manual config neededagents/bunqueue-assistant.md, specialized agent that Claude auto-delegates to for bunqueue tasks (setup, debugging, migration, optimization)- Updated
plugin.jsonv1.1.0, declares all components (skills, agents, MCP), adds keywords for discoverability
[2.6.94] - 2026-03-31
Section titled “[2.6.94] - 2026-03-31”- Claude Code plugin & skills: AI-native integration for bunqueue (closes #71):
.claude-plugin/plugin.json, distributable plugin manifest, installable via/plugin marketplace add egeominotti/bunqueueskills/bunqueue/SKILL.md, public skill with Simple Mode (all 12 features), Queue+Worker, auto-batching, QueueGroup, webhooks, S3 backup, MCP server, BullMQ migration guideskills/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 checklistskills/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
Section titled “[2.6.93] - 2026-03-31”- Deduplication bypass while job is active:
handleDeduplicationnow checksjobIndexfor active/processing jobs, not just the priority queue. Previously, pushing a job with the sameuniqueKeywhile the original was still being processed would create a duplicate. Also fixedpushJobfall-through when dedup returnedskip: truebut the job wasn’t in the queue (active). Fixes #69.
[2.6.92] - 2026-03-31
Section titled “[2.6.92] - 2026-03-31”- 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:
rateLimitoption (max/duration/groupKey) + runtimesetGlobalRateLimit() - DLQ Auto-Management:
dlqoption 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
Section titled “[2.6.91] - 2026-03-31”- 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),
retryIfpredicate - 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
Section titled “[2.6.90] - 2026-03-31”- Simple Mode (
Bunqueueclass): new unified API that combines Queue + Worker into a single object. Includes route-based job dispatching, onion-model middleware chain, and simplified cron scheduling viacron()andevery(). Works in both embedded and TCP modes. Import asimport { 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
Section titled “[2.6.89] - 2026-03-30”getPrioritized()returning empty array:end=-1(default) was not normalized in the embedded path ofgetJobsAsync, causingmaxPerSource=0and zero results. Now handlesend=-1consistently with the TCP path.
[2.6.88] - 2026-03-30
Section titled “[2.6.88] - 2026-03-30”- ESLint crash on
flow.ts: removed unnecessary explicit<T>type arguments fromcreateFlowJobObjectcalls that caused@typescript-eslint/no-unnecessary-type-argumentsrule to crash duringbun run lint.
[2.6.87] - 2026-03-30
Section titled “[2.6.87] - 2026-03-30”skipIfNoWorkernot working on restart (#67), when a cron job hadskipIfNoWorker: trueand the server restarted with past-duenextRun, the missed cron fired immediately because workers reconnected before the scheduler tick. Theload()method now recalculatesnextRunto the next future occurrence whenskipIfNoWorkeris enabled, preventing missed cron executions on restart.
[2.6.85] - 2026-03-26
Section titled “[2.6.85] - 2026-03-26”skipIfNoWorkeroption for cron jobs (#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_workercolumn oncron_jobstable
[2.6.84] - 2026-03-26
Section titled “[2.6.84] - 2026-03-26”immediately: trueconflicting withskipMissedOnRestart(#65):immediatelynow only fires on first creation, not on subsequent upserts- Previously, every call to
upsertJobSchedulerwithimmediately: truewould overrideskipMissedOnRestartand fire the cron immediately, even after a server restart - This was the root cause of the TCP-mode report: the user’s app called
upsertJobScheduleron every startup with both flags, causing the cron to fire immediately despiteskipMissedOnRestart
[2.6.83] - 2026-03-26
Section titled “[2.6.83] - 2026-03-26”immediately: truenow works in TCP mode (#65):- Added
immediatelyfield to TCPCroncommand type - Wired
immediatelythrough TCP handler (handleCron) and client TCP path (upsertJobScheduler) - Full TCP parity:
immediately,skipMissedOnRestartnow work identically in both embedded and TCP modes
- Added
[2.6.82] - 2026-03-26
Section titled “[2.6.82] - 2026-03-26”skipMissedOnRestartnot working viaQueue#upsertJobScheduler(#65):CronScheduler.add()now preserves existingexecutionscount when upserting a cron (previously reset to 0 on every call)CronScheduler.load()now persists recalculatednextRunto the database whenskipMissedOnRestartadjusts itimmediately: trueoption is now supported inCronJobInput, fires the cron immediately on creation, then continues on schedule- Wired
immediatelythroughupsertJobSchedulerembedded path
- Embedded
test-cron-event-driventest hanging: addedshutdownManager()call to properly clean up the shared QueueManager singleton and its background task timers
[2.6.81] - 2026-03-26
Section titled “[2.6.81] - 2026-03-26”- Worker API enhancements (BullMQ v5 compatibility):
concurrencygetter/setter, change concurrency at runtime without restarting the workerclosingproperty, Promise that resolves whenclose()finishesoff()typed overloads, remove event listeners with full TypeScript supportnameandoptsare now public readonly properties
- Worker options now fully wired:
skipLockRenewal, disables heartbeat timer whentrueskipStalledCheck, disables stalled event subscription whentruedrainDelay, configurable delay between polls when queue is drained (default: 50ms, was hardcoded)lockDuration, stored in opts with default 30000msmaxStalledCount, stored in opts with default 1removeOnComplete/removeOnFail, worker-level defaults applied to all processed jobs
drainDelaydefault corrected from 5000ms to 50ms in documentation
Removed
Section titled “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
Section titled “[2.6.80] - 2026-03-25”- 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 duringmoveToProcessing(), 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-netrequeueJob()restores jobs to the queue ifmoveToProcessing()fails for any reason
[2.6.79] - 2026-03-25
Section titled “[2.6.79] - 2026-03-25”- Issue #63 follow-up:
getStallConfig()andgetDlqConfig()in TCP mode now return the correct config after callingsetStallConfig()/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
Section titled “[2.6.78] - 2026-03-25”- Issue #61:
JobTemplateis now genericJobTemplate<T>,datafield correctly inherits the Queue’s type parameter instead of beingunknown. Fixed incorrect docs inuse-casesshowingdatain the second parameter instead of the third. ExportedRepeatOpts,JobTemplate,SchedulerInfotypes frombunqueue/client - Issue #63: Cloud dashboard
queue:detailresponse now includesenabledfield installConfig, allowing the dashboard to properly display and toggle stall detection - Issue #64: Added WAL checkpoint (
PRAGMA wal_checkpoint(TRUNCATE)) beforedb.close()to prevent stale locks anddisk I/O erroron rapid restarts in embedded mode
skipMissedOnRestartoption 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_restartcolumn oncron_jobstable
[2.6.77] - 2026-03-24
Section titled “[2.6.77] - 2026-03-24”removeChildDependency()TCP response now returns{ ok: true, removed: boolean }separately; client readsres.removedinstead ofres.okto correctly reflect whether the dependency was actually removed
[2.6.76] - 2026-03-24
Section titled “[2.6.76] - 2026-03-24”- Integration test scripts for monitoring, query operations, cron event-driven scheduling, and sandboxed workers (TCP + embedded modes)
- Unit tests for issues #29 (sandboxed worker
logmethod), #38 (sandboxed processor cleanup), #41 (sandboxed idle RAM)
[2.6.75] - 2026-03-24
Section titled “[2.6.75] - 2026-03-24”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 asremoveDependencyOnFailurebut also stores the failure reason so the parent worker can retrieve it viajob.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 calljob.getFailedChildrenValues()to inspect which children failed and why, andjob.removeUnprocessedChildren()to cancel remaining unstarted children.job.getFailedChildrenValues(): ReturnsRecord<string, string>mapping child keys ("queue:jobId") to their error messages. Populated bycontinueParentOnFailurechild failures.job.getIgnoredChildrenFailures(): ReturnsRecord<string, string>of failure reasons for children that failed withignoreDependencyOnFailure.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 TCPPUSHcommand.
[2.6.74] - 2026-03-23
Section titled “[2.6.74] - 2026-03-23”Changed
Section titled “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
/batchingest endpoint: Recovery now resends buffered snapshots one-by-one to the standard/api/v1/ingestendpoint, simplifying the protocol.
[2.6.73] - 2026-03-23
Section titled “[2.6.73] - 2026-03-23”- 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 includestimelinewhen present, giving the dashboard exact state-transition history for each job. - Cloud snapshot: failed job duration enrichment: Failed jobs in
recentJobs[]are now enriched withduration,completedAt, andtotalDurationfrom DLQ attempt history, sincecompletedAtis null for failed jobs.
[2.6.72] - 2026-03-23
Section titled “[2.6.72] - 2026-03-23”- Cloud snapshot:
waiting-childrenstate: Jobs inwaiting-childrenstate are now collected inrecentJobs[]and counted in both globalstatsand per-queuequeues[]. Dashboard can now display parent jobs waiting for children. - Cloud snapshot:
prioritizedstate in job collection:recentJobs[]now includes jobs withstate: 'prioritized'. Previously onlywaiting/active/delayed/failed/completedwere collected. - Cloud snapshot: worker computed fields:
workerDetails[]now includesuptime(ms since registration),status('active'|'idle'|'stalled'),errorRate(0-1), andutilization(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 returnswaiting-childrencount in job counts response.
getJobs()withstate: 'waiting-children': SQLite and in-memory query paths now correctly return jobs inwaitingDeps/waitingChildrenmaps when filtering bywaiting-childrenstate.
[2.6.71] - 2026-03-23
Section titled “[2.6.71] - 2026-03-23”- BullMQ v5
prioritizedstate: Jobs withpriority > 0now report state'prioritized'instead of'waiting', matching BullMQ v5 exactly. AffectsgetJobState(),getJobCounts(), Prometheus metrics, cloud snapshot, SSE/WebSocket events, and MCP adapter. - BullMQ v5
waiting-childrenstate: Parent jobs in flows correctly report'waiting-children'state while waiting for child jobs to complete. failParentOnFailure: When a child job terminally fails withfailParentOnFailure: true, the parent job is automatically moved tofailedstate. Handles race conditions where child fails before parent linkage is established.- Flow atomicity:
FlowProducer.add()andaddBulk()now automatically roll back all created jobs if any part of the flow fails during creation. FlowOptswithqueuesOptions: Pass per-queue default job options as second argument toflow.add(flowJob, { queuesOptions: { queueName: { attempts: 5 } } }).- FlowProducer extends EventEmitter: BullMQ v5 compatible.
close()returnsPromise<void>,closingproperty tracks shutdown,disconnect()alias. - Job move operations:
moveActiveToWait,changeWaitingDelay,moveToWaitingChildrenstate transitions with proper resource cleanup (concurrency slots, unique keys, group locks).
- TOCTOU in
moveParentToFailed: Re-checksjobIndexinside write lock to prevent duplicate DLQ entries when multiple children withfailParentOnFailurefail concurrently. - Unhandled promise rejections:
moveParentToFailedcalls now have.catch()handlers instead of fire-and-forgetvoid. - SQLite
queryJobs(state='prioritized'): Translates'prioritized'toWHERE state='waiting' AND priority > 0since SQLite never stores ‘prioritized’ as a state value. moveActiveToWaitresource leak: Now callsreleaseJobResources()to free concurrency/uniqueKey/group slots before re-queueing.- Move operations handle
prioritizedstate:moveJobToWaitandmoveJobToDelayednow correctly handle jobs in'prioritized'state. - Cloud snapshot: Added
prioritizedto stats and per-queue data. Per-queue data now usesfailedinstead ofdlq(BullMQ v5 compatible).
Changed
Section titled “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
Section titled “[2.6.67] - 2026-03-22”Changed
Section titled “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
Section titled “[2.6.66] - 2026-03-22”- Deduplication not working for JobScheduler (Issue #60):
upsertJobScheduleraccepted deduplication options in theJobTemplatebut silently discarded them. The cron system (CronJob,CronJobInput,cronScheduler) had no fields foruniqueKeyordedup, 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 topushJob()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
Section titled “[2.6.65] - 2026-03-22”- 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) andmcpSummary(aggregated stats with top tools) fields inCloudSnapshot.
[2.6.64] - 2026-03-21
Section titled “[2.6.64] - 2026-03-21”- No-lock ack fails after stall re-queue (data loss): When a worker with
useLocks=falseprocessed a job that stall detection re-queued, theack()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
Section titled “[2.6.63] - 2026-03-21”Performance
Section titled “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 removesMath.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(), andaddTree()now create independent children/jobs concurrently viaPromise.all. TCP benchmark shows 3–6x speedup for flows with 10–20 children (network round-trips overlap instead of serializing).addBulkThen()usesPromise.allSettledfor proper cleanup on partial failure. No impact in embedded mode (pushes are synchronous).addChain()unchanged (sequential by design).
[2.6.62] - 2026-03-21
Section titled “[2.6.62] - 2026-03-21”- E2E webhook tests failing after SSRF validation: Added
validateWebhookUrlsoption toQueueManagerConfigso tests using localhost can disable URL validation.
[2.6.60] - 2026-03-21
Section titled “[2.6.60] - 2026-03-21”- 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
Section titled “Changed”- Extracted
validateWebhookUrlto shared module:src/shared/webhookValidation.tsis now the single source of truth, re-exported fromprotocol.tsfor backward compatibility.
[2.6.49] - 2026-03-20
Section titled “[2.6.49] - 2026-03-20”- 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
- Queue:
- Shared
deriveStateandmapJobhelpers: Eliminated triplicated state derivation logic in command handlers.
[2.6.48] - 2026-03-20
Section titled “[2.6.48] - 2026-03-20”Changed
Section titled “Changed”- Cloud: auth via HTTP upgrade headers: WebSocket authentication now uses
Authorization,X-Instance-Id, andX-Remote-Commandsheaders 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.
- Cloud: duplicate reconnect guard:
scheduleReconnect()now prevents multiple concurrent reconnect timers. - Cloud:
oncloselogs atinfolevel: Previouslydebug, making reconnect failures invisible in production logs.
[2.6.47] - 2026-03-20
Section titled “[2.6.47] - 2026-03-20”- Programmatic
dataPathfor embedded mode: Queue and Worker acceptdataPathoption to set the SQLite database path without env vars. Resolves conflicts with apps that use their ownDATA_PATH. (#59) BUNQUEUE_DATA_PATH/BQ_DATA_PATHenv 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
Section titled “[2.6.46] - 2026-03-20”- Cloud: resilient WebSocket with ring buffer: Events are buffered (max 1000) when WS is disconnected and flushed after
handshake_ackon 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.
- Cloud: double reconnect race: Pong timeout no longer calls
scheduleReconnect()directly; delegates tooncloseto prevent duplicate sockets. - Cloud: local socket reference: All handlers (pong, handshake, commands) use the local
wsvariable, notthis.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
Section titled “[2.6.45] - 2026-03-20”- Cloud:
prevanddelayfields in WebSocket events: CloudEvent now forwards all JobEvent fields:prev(previous state on removed/retried) anddelay(ms for delayed jobs).
- 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
Section titled “[2.6.44] - 2026-03-20”- Cloud: WebSocket ping/pong heartbeat: Pong responses are now sent regardless of
BUNQUEUE_CLOUD_REMOTE_COMMANDSconfig. 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
Section titled “[2.6.43] - 2026-03-19”- Cloud:
job:listcommand: Paginated job listing per queue with state filtering (queue,state,limit,offset). - Cloud:
job:getcommand: Full job detail with logs and result included. - Cloud:
queue:detailcommand: Queue detail with counts, config, DLQ entries, and job list.
- Cloud: recentJobs now includes completed/failed jobs: Was only querying waiting/active/delayed states.
- Cloud:
job:listtotal 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
Section titled “[2.6.42] - 2026-03-19”Performance
Section titled “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.
- 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
Section titled “[2.6.41] - 2026-03-19”Enhanced
Section titled “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. Addedjob:logsandjob:resultremote commands for on-demand data. Auth errors (401/403) now logged at error level instead of silently buffered.
[2.6.40] - 2026-03-19
Section titled “[2.6.40] - 2026-03-19”Added (Beta)
Section titled “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. RequiresBUNQUEUE_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
Section titled “[2.6.39] - 2026-03-18”EventType.Paused/EventType.Resumedmissing from enum: AddedPausedandResumedvariants toEventTypeconst enum, fixing TypeScript compilation errors inqueueManager.tsandclient/events.ts.UnrecoverableError/DelayedErrornot exported: Addedsrc/client/errors.tswith BullMQ-compatible error classes (UnrecoverableErrorto skip retries,DelayedErrorto re-delay jobs) and exported them frombunqueue/client.- Webhook mapping for pause/resume events:
eventsManager.tsnow handlesPausedandResumedevent types in the webhook switch.
- Issue #53 test: Regression test for worker
logevent firing.
[2.6.38] - 2026-03-18
Section titled “[2.6.38] - 2026-03-18”- Worker registration + heartbeat system: Worker SDK now auto-registers with the server on
run(), sends periodic heartbeats withactiveJobs/processed/failedstats, and unregisters onclose(). The server trackshostname,pid,uptimeper worker.GET /workersandListWorkersTCP command return full worker details including aggregate stats. Dashboard receives real-time events (worker:connected,worker:heartbeat,worker:disconnected). RegisterWorkerCommandextended: AcceptsworkerId,hostname,pid,startedAtfrom client. Re-registration with sameworkerIdupdates instead of duplicating.HeartbeatCommandextended: AcceptsactiveJobs,processed,failedto sync client-side stats to server.onOutcomecallback in processor: Tracks completed/failed counts without adding event listeners.
Removed
Section titled “Removed”- Flaky embedded tests (sandboxed-workers, cron-event-driven, query-operations)
[2.6.37] - 2026-03-17
Section titled “[2.6.37] - 2026-03-17”getJobCountsnow returnsdelayedandpausedcounts: Matches BullMQ’sgetJobCounts()return type. Both embedded and TCP modes includedelayed(jobs with futurerunAt) andpaused(waiting jobs count when queue is paused). (#56)getJobssupports multiple statuses: Acceptsstring | string[]for thestateparameter, matching BullMQ’sgetJobs(types?: JobType | JobType[])interface. Works in embedded, TCP, and HTTP (?state=waiting&state=delayed). (#55)GET /queues/summaryendpoint: Returns all queues with name, paused status, and job counts in a single HTTP call, replacing N+1 round-trips.
Removed
Section titled “Removed”- Flaky TCP integration tests (sandboxed-worker, monitoring)
[2.6.36] - 2026-03-17
Section titled “[2.6.36] - 2026-03-17”/queues/:queue/jobs/listperformance: Endpoint was taking 300-450ms even withlimit=2because 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 withLIMIT/OFFSET, reducing response time to <5ms.
[2.6.35] - 2026-03-16
Section titled “[2.6.35] - 2026-03-16”Changed
Section titled “Changed”- Removed flaky SandboxedWorker flow failure test
[2.6.34] - 2026-03-16
Section titled “[2.6.34] - 2026-03-16”- QueueEvents failed events:
failedReasonnow correctly reads fromevent.errorinstead ofevent.data, jobdatais included in failed broadcasts, and error emission includes event context. (#54), thanks @simontong
Changed
Section titled “Changed”- CI: Disabled TCP and Embedded integration tests in GitHub Actions pipeline
- Removed flaky SandboxedWorker tests
[2.6.33] - 2026-03-16
Section titled “[2.6.33] - 2026-03-16”- Worker
logevent:worker.on('log', (job, message) => ...)now works with full TypeScript autocomplete. Thelogevent is emitted whenjob.log()is called inside the processor, matching SandboxedWorker behavior. (#53)
[2.6.32] - 2026-03-16
Section titled “[2.6.32] - 2026-03-16”- 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:skippedinstead. - Flow lifecycle events:
flow:completedwhen all children of a parent job finish,flow:failedwhen a child permanently fails (moves to DLQ).
Changed
Section titled “Changed”- SandboxedWorker docs: Clearly marked as experimental across all documentation pages (worker, migration, CPU-intensive, stall-detection, troubleshooting). Production recommendation to use standard
Workerinstead.
[2.6.31] - 2026-03-16
Section titled “[2.6.31] - 2026-03-16”- SandboxedWorker
autoStartoption: Automatically restart the worker pool when new jobs arrive after idle shutdown. SetautoStart: truewithidleTimeoutto get workers that sleep when idle and wake up when needed. Configurable poll interval viaautoStartPollMs(default: 5000ms). Closes #51.
[2.6.30] - 2026-03-16
Section titled “[2.6.30] - 2026-03-16”- 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
Section titled “[2.6.29] - 2026-03-16”- 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
Section titled “[2.6.28] - 2026-03-15”getChildrenValuesempty in TCP mode: Fixed response envelope unwrap in worker processor (response.data.valuesinstead ofresponse.values). FixedchildrenIds/parentIdnot passed through TCP protocol in flow jobs. (#49, PR by @simontong)
[2.6.27] - 2026-03-15
Section titled “[2.6.27] - 2026-03-15”getJobreturns null for failed/DLQ jobs: In embedded mode (no SQLite storage),getJob()andgetJobByCustomId()now correctly query the shard DLQ instead of returning null. (#50)getChildrenValueswired in worker: Worker job processor now correctly passes thegetChildrenValuescallback.
- WebSocket/SSE integration tests: 88 new integration tests covering WebSocket and SSE event streaming.
[2.6.26] - 2026-03-15
Section titled “[2.6.26] - 2026-03-15”- 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.
- 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
Section titled “[2.6.25] - 2026-03-14”GET /queues/:q/workerscrash: Fixed crash when some workers were registered without aqueuesfield (undefined/null). Now safely skips workers with missing queues and defaults to[]on creation.
[2.6.24] - 2026-03-14
Section titled “[2.6.24] - 2026-03-14”- Per-queue completed count:
GET /queues/:q/countscompletedfield 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/dlqnow returnsDlqEntry[]withenteredAt,reason,error,retryCount,lastRetryAt,nextRetryAt,expiresAtinstead of rawJob[]. - Worker registration accepts
queue(singular):POST /workersnow accepts bothqueue(string) andqueues(array), plusworkerIdas alias forname.
- Per-queue
totalCompleted/totalFailedcounters:GET /queues/:q/countsnow includes cumulative per-queue counters for completed and failed jobs. GET /queues/:q/workersendpoint: New endpoint to list workers registered for a specific queue.GET /queues/:q/dlq/statsendpoint: Server-side DLQ stats aggregation:total,byReason,pendingRetry,oldestEntry.- Worker
concurrency,status,currentJobfields:GET /workersandPOST /workersresponses now includeconcurrency, computedstatus(active/stale), andcurrentJob. - Throughput rates in
GET /stats: AddedpushPerSec,pullPerSec,completePerSec,failPerSecfrom the built-in throughput tracker.
[2.6.23] - 2026-03-14
Section titled “[2.6.23] - 2026-03-14”- Dashboard beta demo: Added demo video and beta CTA to README and docs introduction page.
[2.6.22] - 2026-03-14
Section titled “[2.6.22] - 2026-03-14”- 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
progresswasundefinedbecause the broadcast didn’t set the top-level field.
- 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
Section titled “[2.6.21] - 2026-03-14”Performance
Section titled “Performance”- Batch push notifyBatch(): Batch push now wakes all waiting workers correctly via
notifyBatch(N)instead of a singlenotify()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
Section titled “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/:queuenow 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.
- 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
Section titled “[2.6.20] - 2026-03-14”- Centralized HTTP JSON body parsing: Replaced per-file
parseBody()with sharedparseJsonBody()that returns proper 400 responses for invalid JSON instead of silently falling back to{}. - Dashboard pagination: Added
limitandoffsetquery parameters toGET /dashboard/queues. Workers and crons lists capped at 100 entries withtruncatedflag. - ESLint complexity reduction: Extracted job push/pull/bulk operations into
routeJobOps()helper to keeprouteQueueRoutesunder the 45-branch complexity limit.
[2.6.19] - 2026-03-14
Section titled “[2.6.19] - 2026-03-14”- WebSocket idle timeout (ping/pong): Set
idleTimeout: 120on 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
Section titled “[2.6.18] - 2026-03-14”- 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:snapshotevery 5s (global stats, per-queue counts, throughput, workers),health:statusevery 10s (uptime, memory, connections),storage:statusevery 30s (collection sizes, disk health). queue:countsevent: 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
Section titled “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
Section titled “[2.6.17] - 2026-03-14”- Memory leak in HTTP client tracking: Every HTTP PULL+ACK cycle created an orphaned entry in the
clientJobsMap that was never cleaned up. Over time this grew unbounded. Fix: HTTP requests no longer setclientId(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
Section titled “[2.6.16] - 2026-03-14”- PUSH
maxAttemptssilently ignored via HTTP: The HTTP endpoint mappedattemptsinstead ofmaxAttempts, causing retry configuration to be discarded. Now correctly maps tomaxAttempts(also acceptsattemptsfor backwards compatibility). - GetJobs pagination broken via HTTP: The HTTP endpoint sent
start/endinstead ofoffset/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-batchwere intercepted by the generic/jobs/:idpattern. Fixed by matching exact batch paths before the wildcard pattern.
[2.6.15] - 2026-03-14
Section titled “[2.6.15] - 2026-03-14”- 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
Section titled “[2.6.14] - 2026-03-14”- CLI double execution: Every CLI command ran twice due to
main()being called both on module load and on import. Addedimport.meta.mainguard. - 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
detachflag to PULL command for CLI usage. job getshowedState: unknown: GetJob response didn’t include job state. Now includes state fromgetJobState().queue jobsstate column showed-: GetJobs handler didn’t include state per job. Now injects state for each returned job.bunqueue -p <port>(withoutstart) 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. Addedunwrap()helper. - Cron list showed
OK: Server returnscronskey but formatter checked forcronJobs. - Worker/webhook list showed stats instead of entries:
statscheck ran beforeworkers/webhooksin formatter priority order. - Worker register showed queue list: Response
queuesfield triggered queue list formatter. - DLQ list format broken: Formatter expected
jobIdfield but server returnsid. - Metrics showed
OK: Prometheus metrics nested indata.metrics.
[2.6.9] - 2026-03-10
Section titled “[2.6.9] - 2026-03-10”- SandboxedWorker graceful stop:
stop()now drains active jobs before terminating worker threads, preventing data loss when stopping during job processing. Addedforceparameter for immediate termination when needed. (#39)
[2.6.7] - 2026-03-08
Section titled “[2.6.7] - 2026-03-08”- 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) - 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.
- 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
- 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
Section titled “Performance”- CronScheduler
scheduleNext()now handles stale entries in O(k) amortized instead of blocking indefinitely
[2.6.6] - 2026-03-07
Section titled “[2.6.6] - 2026-03-07”- Parent-child flow race condition: Resolved race where concurrent ack/fail operations on parent-child flows could cause inconsistent state. (#31)
- Embedded Worker heartbeats: Fixed embedded Worker heartbeat mechanism not properly keeping jobs alive during long processing. (#32)
[2.6.5] - 2026-03-06
Section titled “[2.6.5] - 2026-03-06”- SandboxedWorker
logevent not emitted: The processor’sjob.log()method stored logs viaaddLog()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) - SandboxedWorker embedded heartbeats missing: In embedded mode,
sendHeartbeatwas a no-op andheartbeatIntervaldefaulted to 0 (timer never started). Long-running jobs withoutprogress()calls were detected as stalled and moved to DLQ despite still running. NowsendHeartbeatcallsmanager.jobHeartbeat()and defaults to 5000ms. (#30)
- 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)
- Updated SandboxedWorker processor example with
log(),fail(), andparentIdfields - Fixed
heartbeatIntervaldefault from0to5000in embedded mode docs - Added
logevent to SandboxedWorker Event Reference (8 events total) - Added SandboxedWorker section to Stall Detection guide
- Updated SandboxedWorkerOptions type with
heartbeatIntervalandconnectionfields
[2.6.4] - 2026-03-05
Section titled “[2.6.4] - 2026-03-05”- 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)
- SandboxedWorker generics:
SandboxedWorker<T>now supports a generic type parameter for typed events (e.g.,worker.on('completed', (job: Job<MyData>) => ...)) - Processor API improvements: Processor files now receive
log(),fail(), andparentIdon the job object alongsideprogress() - Typed
on()/once()overloads for all SandboxedWorker events (#25)
[2.6.2] - 2026-03-03
Section titled “[2.6.2] - 2026-03-03”job.namealways'default'for scheduled jobs: When jobs were created viaQueue#upsertJobScheduler, thenamefromjobTemplatewas not embedded in the cron job data. The worker fell back to'default'. Now embeds the name in data, matchingQueue.add()behavior. (Discussion #23)
- Regression test for scheduler job name passthrough (
test/bug-23-scheduler-job-name.test.ts)
- 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
maxMemoryfor large file processing - Fixed missing
awaitonworker.start()calls - Improved Worker vs SandboxedWorker comparison table
[2.6.1] - 2026-03-03
Section titled “[2.6.1] - 2026-03-03”Queue#upsertJobSchedulerignoring timezone: TheRepeatOptsinterface was missing thetimezonefield, causing a TypeScript error when setting it. Additionally, embedded mode hardcodedtimezone: '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)
- Regression test for scheduler timezone passthrough (
test/bug-22-scheduler-timezone.test.ts)
[2.6.0] - 2026-03-03
Section titled “[2.6.0] - 2026-03-03”- 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 silentUnknown commanderrors in TCP mode. All 8 are now fully functional. updateJobData/updateJobChildrenIdspersistence methods added toSqliteStoragefor parent-child relationship durability.- 20 new regression tests covering all fixes in this release.
- Expired lock requeue not updating stats: When a job’s lock expired and was requeued for retry,
requeueExpiredJobinlockManager.tsdid not callshard.incrementQueued()orshard.notify(). This causedgetStats()to report 0 waiting jobs and workers in long-poll mode to not wake up for the requeued job. updateJobParentnot persisting to SQLite:childrenIdsand__parentIdmutations were only applied in memory. After a server restart, all parent-child flow relationships were lost. Now properly persisted via dedicated SQLite update methods.getJobreturning null for completed jobs without storage: In no-SQLite mode (embedded without persistence),getJob()returnednullfor completed/DLQ jobs because it only checkedctx.storage?.getJob(). Now falls back toctx.completedJobsDatain-memory map.- MCP
UnregisterWorkerfield 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. JobHeartbeatignoringdurationfield: When the MCP adapter sent aJobHeartbeatwith a customduration, the handler ignored it and renewed the lock with the default TTL. Now properly extends the lock with the requested duration viarenewJobLock().
[2.5.8] - 2026-03-02
Section titled “[2.5.8] - 2026-03-02”- Repeat job updateData:
updateData()now propagates to the next repeat execution. Previously, callingupdateData()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) - Worker event IntelliSense: Worker now has typed
on()andonce()overloads for all 10 events (ready,active,completed,failed,progress,stalled,drained,error,cancelled,closed), providing full TypeScript autocomplete. (#15)
FlowJobDatatype: New exported interface for flow-injected fields (__flowParentId,__flowParentIds,__parentId,__parentQueue,__childrenIds).Processor<T, R>now intersectsTwithFlowJobDatafor automatic IntelliSense in Worker callbacks. (#18)- CLI env var auth: CLI now reads
BQ_TOKEN/BUNQUEUE_TOKENenvironment variables as fallback when--tokenis not provided. Priority:--tokenflag >BQ_TOKEN>BUNQUEUE_TOKEN. (#13)
- Updated Worker guide with typed event reference table
- Updated Flow guide with
FlowJobDatatype 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
Section titled “[2.5.7] - 2026-03-01”- 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
connectionoption 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
JobHeartbeatcommands for active jobs in TCP mode (configurable viaheartbeatInterval). - TCP integration test for SandboxedWorker (
scripts/tcp/test-sandboxed-worker.ts) - 8 new unit tests for SandboxedWorker events and TCP constructor
- 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
Section titled “[2.5.6] - 2026-02-27”- 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-sideGetChildrenValues, batch-fetch children return values in a single command instead of N+1 queriesStorageStatus, return real disk/storage health from the server instead of hardcodeddiskFull: false
- 9 new tests for the 3 TCP commands (
test/tcp-new-commands.test.ts)
- MCP TCP
getCron(name): now uses dedicatedCronGetcommand instead of fetching all crons and filtering client-side - MCP TCP
getChildrenValues(id): now uses dedicatedGetChildrenValuescommand instead of 1 + 2N queries (GetJob parent + GetResult/GetJob per child) - MCP TCP
getStorageStatus(): now uses dedicatedStorageStatuscommand instead of returning hardcoded{ diskFull: false }
[2.5.5] - 2026-02-26
Section titled “[2.5.5] - 2026-02-26”- TCP client auth state corruption:
TcpClient.doConnect()setconnected = truebeforeauthenticate()completed. If authentication failed, the client remained in a corrupted state (connected = truewith no valid session), causing subsequent operations to silently fail. Connection state is now set only after successful authentication, with proper cleanup on failure.
- SEO overhaul, keyword-rich titles, optimized descriptions, AI keywords, sitemap priorities
[2.5.4] - 2026-02-24
Section titled “[2.5.4] - 2026-02-24”- 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 → Cbunqueue_add_flow_bulk_then, fan-out/fan-in: parallel jobs → final mergebunqueue_get_flow, retrieve flow trees with full dependency graph
[2.5.3] - 2026-02-24
Section titled “[2.5.3] - 2026-02-24”- 3 MCP Prompts for AI agents, pre-built diagnostic templates:
bunqueue_health_report, comprehensive server health report with severity levelsbunqueue_debug_queue, deep diagnostic of a specific queuebunqueue_incident_response, step-by-step triage playbook for “jobs not processing”
- 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 withPromise.allinstead of sequential N+1 - MCP resource error format: includes
isError: trueconsistent with tool errors - MCP pool size: configurable via
BUNQUEUE_POOL_SIZEenv var (default: 2)
[2.5.2] - 2026-02-24
Section titled “[2.5.2] - 2026-02-24”- TCP deduplication:
jobIddeduplication now works correctly in TCP mode. The auto-batcher was sendingjobIdinstead ofcustomIdin PUSHB commands, causing the server to skip deduplication for all batched operations (#10) - CLI
--hostand-pflags:bunqueue start --host 127.0.0.1 -p 6666now 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) - Docker healthcheck: Changed healthcheck URL from
localhostto127.0.0.1to avoid IPv6 resolution issues in Alpine containers (#7) - TCP ping health check: Fixed ping response parsing from
response.pongtoresponse.data.pongmatching the actual server response structure (#5)
- 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)
- Updated deployment guide healthcheck example (
localhost→127.0.0.1) - Clarified that
jobIddeduplication works in both embedded and TCP modes - Added
--hostflag example to CLI start command reference
[2.5.1] - 2026-02-23
Section titled “[2.5.1] - 2026-02-23”- MCP error handling: All 66 tool handlers now wrapped with
withErrorHandlerthat catches backend exceptions and returns structured{ error: "message" }responses withisError: trueinstead 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, andbunqueue_get_cronnow returnisError: truewhen resource is not found
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
Section titled “[2.5.0] - 2026-02-21”Changed
Section titled “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
McpBackendadapter interface
- 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
Section titled “[2.4.8] - 2026-02-16”getJobs({ state: 'completed' })now correctly returns completed jobs instead of empty results
[2.4.7] - 2026-02-14
Section titled “[2.4.7] - 2026-02-14”Performance
Section titled “Performance”-
Event-driven cron scheduler - Replaced 1s
setIntervalpolling with precisesetTimeoutthat 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
setIntervalsafety fallback catches edge cases (timer drift, missed events). Zero functional changes, zero API changes.
scripts/embedded/test-cron-event-driven.ts- Operational test verifying cron timer precision
[2.4.6] - 2026-02-14
Section titled “[2.4.6] - 2026-02-14”Performance
Section titled “Performance”-
Event-driven dependency resolution - Replaced 100ms
setIntervalpolling 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
processPendingDependencieswhen queue is empty).
src/benchmark/dependency-latency.bench.ts- Benchmark for dependency chain resolution latencysrc/application/taskErrorTracking.ts- Extracted error tracking for reuse across modules
[2.4.5] - 2026-02-14
Section titled “[2.4.5] - 2026-02-14”- 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 viaBackoffConfig.maxDelay. - Recovery backoff bypass - Startup recovery now uses
calculateBackoff(job)instead of an inline exponential formula, correctly respectingbackoffConfig(e.g.,{ type: 'fixed', delay: 5000 }was ignored during recovery).
[2.4.3] - 2026-02-14
Section titled “[2.4.3] - 2026-02-14”- Batch push now wakes all waiting workers -
pushJobBatchpreviously callednotify()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.pendingNotificationwas 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
Section titled “[2.4.2] - 2026-02-13”- 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
Section titled “[2.4.1] - 2026-02-12”Changed
Section titled “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.tssrc/client/jobConversion.ts(499 lines) → 269 lines +jobConversionTypes.ts,jobConversionHelpers.tssrc/domain/queue/shard.ts(554 lines) → 484 lines +waiterManager.ts,shardCounters.tssrc/application/queueManager.ts(820 lines) → 774 lines (movedgetQueueJobCountstostatsManager.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
Section titled “[2.4.0] - 2026-02-11”- Auto-batching for
queue.add()over TCP - Transparently batches concurrentadd()calls intoPUSHBcommands- Zero overhead for sequential
awaitusage (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 }
- Zero overhead for sequential
- 306 new tests covering previously untested modules
[2.3.1] - 2026-02-08
Section titled “[2.3.1] - 2026-02-08”- 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
Section titled “[2.3.0] - 2026-02-06”- 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
/statsendpoint - New file:
src/application/throughputTracker.ts
- LOG_LEVEL Runtime Filtering -
LOG_LEVELenv var now works at runtime- Levels:
debug,info(default),warn,error - Priority-based filtering with early return
- Levels:
- 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
Section titled “Changed”/statsendpoint 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
Section titled “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
Section titled “[2.1.8] - 2026-02-06”- pushJobBatch event emission -
pushJobBatchwas silently dropping event broadcasts, causing subscribers and webhooks to miss all batch-pushed jobs. Added broadcast loop after batch insert to match singlepushJobbehavior.
- 4 regression tests for batch push event emission fix
Changed
Section titled “Changed”- Navbar simplified to show only logo without title text
[2.1.7] - 2026-02-05
Section titled “[2.1.7] - 2026-02-05”- WriteBuffer silent data loss during shutdown -
WriteBuffer.stop()swallowed flush errors and silently dropped buffered jobs. AddedreportLostJobs()to notify viaonCriticalErrorcallback when jobs cannot be persisted during shutdown. - Queue name consistency in TCP tests - Fixed port hardcoding in queue-name-consistency test.
- 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
Section titled “[2.1.6] - 2026-02-05”- 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.
- 33 new S3 backup tests covering config validation, backup/restore operations, cleanup, and manager lifecycle
- Documentation for gzip compression, SHA256 checksums,
.meta.jsonfiles, scheduling details, AWS env var aliases, and restore safety notes
[2.1.5] - 2026-02-05
Section titled “[2.1.5] - 2026-02-05”- 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
/bunqueuein paths) - Stray separator in index.mdx causing build error
Changed
Section titled “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
Section titled “[2.1.4] - 2026-02-05”Changed
Section titled “Changed”- README split into Embedded and Server mode sections
- Added Docker server mode quick start with persistence documentation
[2.1.3] - 2026-02-05
Section titled “[2.1.3] - 2026-02-05”- Type safety improvements across client SDK
- Deployment modes section and fixed quick start examples in documentation
Changed
Section titled “Changed”- README improved with use cases, benchmarks, and BullMQ comparison
[2.1.2] - 2026-02-04
Section titled “[2.1.2] - 2026-02-04”- Queue name consistency - Fixed benchmark tests using different queue names for worker and queue in both embedded and TCP modes
Changed
Section titled “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
- Queue name consistency tests to prevent regression
- Monitoring documentation added to sidebar Production section
[2.1.1] - 2026-02-04
Section titled “[2.1.1] - 2026-02-04”- 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
Section titled “Changed”- Docker Compose now supports
--profile monitoringfor optional stack
[2.1.0] - 2026-02-04
Section titled “[2.1.0] - 2026-02-04”Performance
Section titled “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: booleanandmaxInFlight: numberoptions
- 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 scanidx_jobs_group_id: Fast lookup for group operationsidx_jobs_pending_priority: Compound index for priority-ordered job retrievalidx_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)
- 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 handlingtest/client-pipelining.test.ts- 7 tests for client pipeliningtest/server-pipelining.test.ts- 7 tests for server parallel processingtest/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,maxInFlightto public API
- SQLITE_BUSY under high concurrency - Added
PRAGMA busy_timeout = 5000to wait for locks instead of failing immediately - “Database has closed” errors during shutdown - Added
stoppedflag 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, andmaxInFlightoptions to the TCP connection pool.
Changed
Section titled “Changed”- Schema version bumped to 5 (auto-migrates existing databases)
- TCP client now includes
reqIdin 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
Section titled “[2.0.9] - 2026-02-03”- Critical: Memory leak in EventsManager - Cancelled waiters in
waitForJobCompletion()were never removed from thecompletionWaitersmap on timeout. Now properly cleaned up when timeout fires. - Critical: Lost notification TOCTOU race - Fixed race condition in pull.ts where
notify()could fire betweentryPullFromShard()returning null andwaitForJob()being called. AddedpendingNotificationflag 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.
- Comprehensive test suites for all bug fixes:
test/bug-memory-leak-waiters.test.ts- 5 tests verifying memory leak fixtest/bug-lost-notification.test.ts- 4 tests verifying notification fixtest/bug-writebuffer-dataloss.test.ts- 10 tests verifying WriteBuffer fixtest/bug-verification-remaining.test.ts- 7 tests verifying CustomId fix and JS concurrency model
[2.0.3] - 2026-02-02
Section titled “[2.0.3] - 2026-02-02”Changed
Section titled “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
getJob()now properly awaits async manager.getJob() callgetJobCounts()now uses queue-specific counts instead of global statspromoteJobs()implements correct iteration over delayed jobsaddBulk()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
Section titled “[2.0.2] - 2026-02-02”- 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_resultstable (not just in-memorycompletedJobs) - Counter consistency - Fixed
incrementQueued()only called for main queue jobs, notwaitingDeps
- jobId deduplication (
loadCompletedJobIds()method in SQLite storage for dependency recoveryhasResult()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
Section titled “[2.0.1] - 2026-02-02”- Critical: jobId deduplication not working after restart - The
customIdMapwas not populated when recovering jobs from SQLite on server startup. This causedgetDeduplicationJobId()to returnnulland allowed duplicate jobs with the samejobIdto be created.
[2.0.0] - 2026-02-02
Section titled “[2.0.0] - 2026-02-02”- Complete BullMQ v5 API Compatibility - Full feature parity with BullMQ v5
- Worker Advanced Methods
rateLimit(expireTimeMs)- Apply rate limiting to workerisRateLimited()- Check if worker is currently rate limitedstartStalledCheckTimer()- Start stalled job check timerdelay(ms, abortController?)- Delay worker processing with optional abort
- Job Advanced Methods
discard()- Mark job as discardedgetFailedChildrenValues()- Get failed children job valuesgetIgnoredChildrenFailures()- Get ignored children failuresremoveChildDependency()- Remove child dependency from parentremoveDeduplicationKey()- Remove deduplication keyremoveUnprocessedChildren()- Remove unprocessed children jobs
- JobOptions
continueParentOnFailure- Continue parent job when child failsignoreDependencyOnFailure- Ignore dependency on failuretimestamp- Custom job timestamp
- DeduplicationOptions
extend- Extend TTL on duplicatereplace- Replace existing job on duplicate
- Worker Advanced Methods
- Comprehensive Test Coverage - 27 unit tests + 32 embedded script tests for new features
Changed
Section titled “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
Section titled “[1.9.9] - 2026-02-01”- 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
Section titled “Changed”- BullMQ-Style Idempotency -
jobIdoption 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
jobIddeduplication behavior
- Embedded test suite now properly uses embedded mode (was incorrectly trying TCP)
- Fixed
getJobCounts()in tests to use queue-specificgetJobs()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
Section titled “[1.9.8] - 2026-01-31”Changed
Section titled “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
Section titled “[1.9.6] - 2026-01-31”- Durable Writes - New
durable: trueoption 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
Section titled “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
Section titled “[1.9.4] - 2026-01-31”- 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 levelgetJobs()with pagination - Filter by state, paginate withstart/end, sort withascretryCompleted()- Re-queue completed jobs for reprocessing- Advanced deduplication - TTL-based unique keys with
extendandreplacestrategies
Changed
Section titled “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
Section titled “[1.9.3] - 2026-01-31”- Unix Socket Support - TCP and HTTP servers can now bind to Unix sockets
- Configure via
TCP_SOCKET_PATHandHTTP_SOCKET_PATHenvironment variables - CLI flags
--tcp-socketand--http-socket - Lower latency for local connections
- Configure via
- Socket status line in startup banner
- Test alignment for shard drain return type
[1.9.2] - 2026-01-30
Section titled “[1.9.2] - 2026-01-30”- Critical Memory Leak - Resolved
temporalIndexleak causing 5.5M object retention after 1M jobs- Added
cleanOrphanedTemporalEntries()method to Shard - Memory now properly released after job completion with
removeOnComplete: true heapUseddrops to ~6MB after processing (vs 264MB before fix)
- Added
Changed
Section titled “Changed”- Improved error logging in ackBatcher flush operations
[1.9.1] - 2026-01-29
Section titled “[1.9.1] - 2026-01-29”- 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
stallTimeoutsupport in client push options- Advanced health checks for TCP connections
- Defensive checks and cleanup for TCP pool and worker
- Server banner alignment between CLI and main.ts
Changed
Section titled “Changed”- Modularized client code into separate TCP, Worker, Queue, and Sandboxed modules
[1.9.0] - 2026-01-28
Section titled “[1.9.0] - 2026-01-28”- 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
Section titled “Changed”- Connection pool enabled by default for TCP clients
- Improved ESLint compliance across TCP client code
[1.6.8] - 2026-01-27
Section titled “[1.6.8] - 2026-01-27”- Renamed bunq to bunqueue in Dockerfile
- CLI version now read dynamically from package.json
Changed
Section titled “Changed”- Centralized version in
shared/version.ts
[1.6.7] - 2026-01-26
Section titled “[1.6.7] - 2026-01-26”- Dynamic version badge in documentation
- Mobile-responsive layout improvements
- Comprehensive stress tests
[1.6.6] - 2026-01-25
Section titled “[1.6.6] - 2026-01-25”- Counter updates when recovering jobs from SQLite on restart
[1.6.5] - 2026-01-24
Section titled “[1.6.5] - 2026-01-24”- Production readiness improvements with critical fixes
[1.6.4] - 2026-01-23
Section titled “[1.6.4] - 2026-01-23”- SQLite persistence for DLQ entries
- Client SDK persistence issues
[1.6.3] - 2026-01-22
Section titled “[1.6.3] - 2026-01-22”- 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
- Deployment guide documentation corrections
[1.6.2] - 2026-01-21
Section titled “[1.6.2] - 2026-01-21”- SandboxedWorker - Isolated worker processes for crash protection
- Hono and Elysia integration guides
- Section-specific OG images and sitemap
Changed
Section titled “Changed”- Enhanced SEO with Open Graph and Twitter meta tags
- Improved mobile responsiveness in documentation
[1.6.1] - 2026-01-20
Section titled “[1.6.1] - 2026-01-20”- Bunny ASCII art in server startup and CLI help
- Professional benchmark charts using QuickChart.io
- BullMQ vs bunqueue comparison benchmarks
Changed
Section titled “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
Section titled “[1.6.0] - 2026-01-19”- 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
Section titled “Changed”- Updated banner to “written in TypeScript”
- Version now read from package.json dynamically
- DLQ entry return type consistency
[1.5.0] - 2026-01-15
Section titled “[1.5.0] - 2026-01-15”- S3 backup with configurable retention
- Support for Cloudflare R2, MinIO, DigitalOcean Spaces
- Backup CLI commands (now, list, restore, status)
Changed
Section titled “Changed”- Improved backup compression
- Better error messages for S3 configuration
[1.4.0] - 2026-01-10
Section titled “[1.4.0] - 2026-01-10”- Rate limiting per queue
- Concurrency limiting per queue
- Prometheus metrics endpoint
- Health check endpoint
Changed
Section titled “Changed”- Optimized batch operations (3x faster)
- Reduced memory usage for large queues
[1.3.0] - 2026-01-05
Section titled “[1.3.0] - 2026-01-05”- Cron job scheduling
- Webhook notifications
- Job progress tracking
- Job logs
- Memory leak in event listeners
- Race condition in batch acknowledgment
[1.2.0] - 2025-12-28
Section titled “[1.2.0] - 2025-12-28”- Priority queues
- Delayed jobs
- Retry with exponential backoff
- Job timeout
Changed
Section titled “Changed”- Improved SQLite schema with indexes
- Better error handling
[1.1.0] - 2025-12-20
Section titled “[1.1.0] - 2025-12-20”- TCP protocol for high-performance clients
- HTTP API with WebSocket support
- Authentication tokens
- CORS configuration
[1.0.0] - 2025-12-15
Section titled “[1.0.0] - 2025-12-15”- Initial release
- Queue and Worker classes
- SQLite persistence with WAL mode
- Basic DLQ support
- CLI for server and client operations