bunqueue v2.8.47
Fixes 40 bugs. An injected clock for the workflow engine, A deterministic simulation suite, property-based tests over the workflow engine's pure core, saga hardening and more.
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.
To install bunqueue v2.8.47
Section titled “To install bunqueue v2.8.47”bun add bunqueue@2.8.47npm install bunqueue@2.8.47To upgrade bunqueue
Section titled “To upgrade bunqueue”bun add bunqueue@latestAn injected clock for the workflow engine
Section titled “An injected clock for the workflow engine”(clock.ts). Every Date.now(),
Math.random() and timer in src/client/workflow/ now reads from it, and the real
clock is the default, so nothing changes unless a test installs simulatedClock(seed).
With one installed, retry backoff, signal timeouts, execution ids and persisted
timestamps all become functions of that seed, so a failure replays exactly instead of
once in eleven campaigns. Measured on a live run with two retries: 1794 ms of wall
time becomes 66 ms, with the waiting visible on the simulated clock instead.
SQLite, the queue’s worker loop and the OS scheduler are still real, so the engine as
a whole is not deterministic; its own contribution is.
A deterministic simulation suite
Section titled “A deterministic simulation suite”(test/workflow-dst.test.ts).
Property-based tests over the workflow engine’s pure core
Section titled “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
Section titled “Saga hardening”The failing step and sub-workflow records are part of the unwind
set; a failed reversal parks the run in the non-terminal compensation-stuck state,
with engine.resumeCompensation() and engine.abandonCompensation() as the ways
out; .pivot() marks a point of no return past which nothing is rolled back; and
rollbackStatus is tracked separately from failureReason, because “the payment
failed” and “the refund never went through” need different alerts.
Idempotency keys
Section titled “Idempotency keys”On every step, shaped run:step#occurrence:direction and
invariant across retries and crash-resume. Compensate handlers also receive
ctx.forwardIdempotencyKey, so a rollback can ask a provider whether the forward
operation actually happened.
Loop memoisation
Section titled “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
Section titled “Versioned API reference”At /reference/<version>/, generated from source with
TypeDoc over the package’s own exports map. Build it with bun run docs:api.
A dedicated Workflow Engine section in the guide
Section titled “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.
More additions
Section titled “More additions”- 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. - Types reachable from
ExecutionandStepRecordare now exported and nameable:RollbackStatus,CompensationStatus,CompensationOutcome,BranchConditionandWorkflowNode.
Changes
Section titled “Changes”-
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.
Bugfixes
Section titled “Bugfixes”-
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 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.