bunqueue v2.9.14
blog · release
bunqueue v2.9.14
Fixes 10 bugs. waitUntilFinished returned undefined, or Job <id> not found, for a job added with removeOnComplete: true (#155).
To install bunqueue v2.9.14
Section titled “To install bunqueue v2.9.14”bun add bunqueue@2.9.14npm install bunqueue@2.9.14TAG=2.9.14docker run -p 6789:6789 -p 6790:6790 ghcr.io/egeominotti/bunqueue:$TAGTo upgrade bunqueue
Section titled “To upgrade bunqueue”bun add bunqueue@latestUpgrade 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.
Bugfixes
Section titled “Bugfixes”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.
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.
Thanks to 1 contributor!
Section titled “Thanks to 1 contributor!”← bunqueue v2.9.13 · Release on GitHub · Commits since v2.9.13 · Full changelog · All posts