bunqueue v2.9.11
Fixes 23 bugs. 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.
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.
To install bunqueue v2.9.11
Section titled “To install bunqueue v2.9.11”bun add bunqueue@2.9.11npm install bunqueue@2.9.11TAG=2.9.11docker 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”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.
Changes
Section titled “Changes”- 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.
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.
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.
Bugfixes
Section titled “Bugfixes”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.
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.
Thanks to 1 contributor!
Section titled “Thanks to 1 contributor!”← bunqueue v2.9.10 · Release on GitHub · Commits since v2.9.10 · Full changelog · All posts