bunqueue v2.7.13
blog · release
bunqueue v2.7.13
Fixes 6 bugs. WriteBuffer silent data loss when retries exhausted: SqliteStorage previously constructed WriteBuffer without an onCriticalError callback, so jobs dropped after maxRetries (10) vanished with no recovery path (the…
To install bunqueue v2.7.13
Section titled “To install bunqueue v2.7.13”bun add bunqueue@2.7.13npm install bunqueue@2.7.13To upgrade bunqueue
Section titled “To upgrade bunqueue”bun add bunqueue@latestBugfixes
Section titled “Bugfixes”- 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.
Thanks to 1 contributor!
Section titled “Thanks to 1 contributor!”← bunqueue v2.7.12 · Release on GitHub · Commits since v2.7.12 · Full changelog · All posts