Notable changes to @imqueue/core. Entries start with the first release whose
behavior changes needed a written record; earlier history is in the git log.
A released version absent from this file changed no behavior — it was a documentation, CI or packaging-only release. Every release that changed what the queue does has an entry.
This project adheres to Semantic Versioning.
-
TLS on connections to the redis broker. A new
tlsoption encrypts every connection a queue opens — reader, writer, watcher and subscription alike. Passtruefor Node's defaults, or an object handed totls.connect()as given, so a private CA (ca) and mutual TLS (cert/key) both work. The option was previously accepted by the type system and dropped on the way to the client, which meant the bus could not be encrypted at all.Cluster entries may carry their own
tls, overriding the cluster-wide one for that server alone; an entry that omits it falls back to the top level. Per-entryusernameandpasswordare still ignored, exactly as before — honouring them would change what an existing cluster authenticates with, and that has nothing to do with this feature.With
tlsleft unset the environment is consulted —IMQ_REDIS_TLS,IMQ_REDIS_TLS_CA_FILE,IMQ_REDIS_TLS_CERT_FILE,IMQ_REDIS_TLS_KEY_FILE,IMQ_REDIS_TLS_KEY_PASSPHRASE,IMQ_REDIS_TLS_SERVERNAMEandIMQ_REDIS_TLS_REJECT_UNAUTHORIZED— so a deployment can encrypt a fleet without a code change. Certificate files are read as the queue is constructed, and an unreadable one throws: an unmounted secret stops the process rather than leaving it talking to the broker in the clear. Passingtlsexplicitly always wins,tls: falseincluded, andrejectUnauthorized: falseis warned about because it leaves a connection encrypted but unauthenticated.This covers the queue's own connections.
UDPClusterManagerannouncements remain unauthenticated UDP broadcast and are unaffected. -
Integration specs covering TLS against a real broker, in
test/integration/, run bynpm run test-integration. They stand up a throwaway TLS-only redis and assert what a mockediorediscannot: that the handshake completes and is verified, that a message crosses it, that the connection pool keeps differing TLS configurations apart, and that plaintext, an unverifiable certificate and a wrong server name are all refused rather than downgraded. They skip themselves, with a reason, whereverredis-serverandopensslare not both available, so a checkout without redis still passes.npm testnow globstest/unitand does not run them.
-
Nothing changes for a queue that does not use TLS. The option is absent from
optionsunless it was configured, the connection pool key stays the plainhost:port, the redis client is handed notlsoption, and no new warning is emitted. The only added work on that path is one environment lookup per queue construction, about a microsecond, and nothing at all per message. This is covered by its own group of specs rather than left as a claim. -
A connection failure no longer takes the process down during teardown. This is a behaviour change for everyone, not only TLS users: a socket that reported a second failure after the one that closed it used to reach an
errorwith no listener and terminate the process. Such a process now stays up, and the failure is still logged and emitted as it always was. Nothing that previously succeeded behaves differently; a crash becomes a log line. -
Shared writer and watcher connections are now keyed by TLS configuration as well as by
host:port. These connections are shared per server within a process; queues reaching one server under different TLS configurations now get separate connections, so a queue that asked for encryption can never be handed a plaintext socket another queue opened first, nor the reverse. Configurations equal by value still share, and the publicredisKeystill reports the plainhost:portaddress. -
safeDeliveryTtlis now the longest a message may be worked on, and its default moves from5000to300000. It used to be a hand-off recovery deadline, which meant it bounded nothing that mattered: the key was deleted at dispatch, so it never covered processing at all. It now bounds processing, which is the only thing that recovers a message from a worker that is alive, connected and serving other messages while one handler has wedged on this one. Liveness cannot see that case, and restarting an otherwise healthy process is not a recovery strategy.Set it to the longest a handler in this system can legitimately take, with headroom — a slow upstream is the usual reason for a large value, such as a data vendor with no job API or screen-scraping behind an HTTP call. Too low and a message is reclaimed from a worker still legitimately working on it.
The default rose because the meaning changed: 5000 was a sane hand-off deadline and is a poor processing budget.
-
The maintenance sweep now runs on
watcherCheckDelay(5000 ms by default) rather than onsafeDeliveryTtl. How long a message may be worked on and how often the watcher looks for abandoned ones are different questions, and tying them would make a crashed worker's message wait out a budget meant for a live one.watcherCheckDelayis therefore the worst-case latency for a crashed worker's message coming back; with the watcher check disabled the sweep falls back tosafeDeliveryTtl. -
The reader's blocking pop is half
safeDeliveryTtlcapped at 5000 ms. A lease deadline is stamped before the pop that fills the key, so an uncapped wait would hand a message a sizeable part of its budget already spent. At the old defaults both of these resolve to exactly what they were. -
The safe-delivery changes above alter meanings and defaults only —
safeDelivery,safeDeliveryTtlandwatcherCheckDelayare the same three options they were.
-
A failed connection could crash the process as it was being torn down. The redis client guards its socket with a one-shot
errorlistener, which the failure that brings the connection down spends. A socket that goes on to report a second failure — a rejected TLS handshake is answered by an alert that arrives after the rejection itself — then reached a socket with nothing attached, and the unhandlederrortook the process with it. The queue now installs a durable listener on the socket when a connection first fails and again before teardown, anddestroyChannelno longer strips the client's ownerrorhandler before writing itsQUIT.Reachable without TLS in principle, but TLS is what makes it ordinary: a service started against a broker it cannot verify would reject
start()and then die insidedestroy()rather than reporting the misconfiguration. -
safeDeliverylost a message when a worker died mid-handler. It released the message's worker key the instant the message was dispatched to themessagelistener, so a worker killed while its handler was still running took that message with it and nothing brought it back — the option guaranteed the hand-off while its name and documentation promised guaranteed delivery. The key is now held until the listener has finished with the message.A listener says it has finished by what it returns. Return a promise and the message stays checked out until it settles; return anything else and it is released as the listener returns. Every registered listener is consulted, and the message is released once all the promises they returned have settled — settled, not fulfilled, because a handler that threw has still had its turn and re-delivering on a rejection would retry a poison message forever.
Nothing needs changing to get this: a listener that already returns a promise — as
@imqueue/rpcand@imqueue/jobboth do — is covered as it stands, and a synchronous listener behaves exactly as it did.A message that cannot be unpacked releases its lease rather than coming back on every sweep.
-
A dead worker's message is recovered by asking the broker, not a clock. The worker key now carries the identity of the process that took it, and the watcher reclaims it once that process has left
CLIENT LIST— detected as fast as the socket closes, and needing nothing renewed to keep a live lease alive. Reconnecting workers get one sweep of grace, mirroring what the cleanup pass already gave them, so a reconnect backoff does not cost a duplicate. If the client list cannot be read at all, liveness is unknown and a lease is left to its budget rather than guessed at.
Release-at-dispatch is still available per listener, and exactly: do not return the handler's promise.
// held until the handler finishes — the fix
queue.on('message', async message => { await handle(message); });
// released at dispatch, bit-for-bit the old behaviour
queue.on('message', message => { void handle(message); });Note that safeDeliveryTtl is not the lever for this. It never bounded a
dispatched message's lifetime before — the key was already deleted — and it does
not bound one now, so no value of it reproduces the old behaviour.
- A synchronous listener needs no change.
- A listener returning a promise now holds its message for the life of that
promise, bounded by
safeDeliveryTtl. Check that budget is above your slowest handler, since the default of five minutes is a guess about your workload and nothing else. - Messages that used to vanish when a worker died now come back, so a handler that was quietly getting away with not being idempotent will start seeing repeats. Delivery was documented as at-least-once throughout; this makes the implementation match.
- Worker keys written by 3.x carry no owner and are still honoured by their deadline, so a rolling upgrade does not strand them. New keys put the owner before the deadline, so a 3.x watcher still reads the queue off the front and a number off the end rather than parsing garbage. While a 3.x watcher is the elected one it will still reclaim 4.x leases on that deadline, so expect duplicates for the length of the upgrade and finish it promptly.
- No new redis traffic and no new timers: the maintenance tick already read
CLIENT LISTfor the cleanup pass and lease recovery shares that one read. Nothing is renewed, polled or scheduled per message — a heartbeat would prove only that the event loop is free, not that work is progressing, and so would reclaim exactly when a worker is busiest.
-
Silent failures of the transport are now reported without
verbose. Every line below is written through the configured logger, carries the queue, channel or host it is about and never the message payload, the arguments, a redis key or an error text. Nothing is scheduled and no timer is added.- A write to redis rejected inside
send()— the caller already holds the message id and gets no rejection, so this was observable only through the optionalerrorHandler, which most callers do not pass. Reported as a failure episode per queue instance: the first rejected write is logged with its operation, message id and code, further rejections are only counted, and the first successful write logs the recovery together with that count. A failure a redis client delivers twice — through both its command callback and its returned promise — is counted once, whileerrorHandlerkeeps being invoked per delivery, exactly as before. - Safe reading of a queue ending on anything other than a planned stop, reconnect or destroy. A planned stop stays quiet, as before.
- A failure of the periodic watcher-existence check itself; the failures of delayed-message processing and of watcher initialization were already logged and are not duplicated.
- A subscription being established and being restored after a reconnect, and a failed reconnection attempt — the absence of the restore line after a reconnect is what makes a lost subscription provable.
- Safe-delivery maintenance disabling itself for good when the writer connection is gone.
- Messages of expired worker leases being re-queued — aggregated per processing pass into one line per destination queue with a count — and a worker key that could not be deleted: the two causes of a duplicate delivery.
- Keys removed by the built-in cleanup, with the number of candidates and the number actually deleted.
- A publish whose channel has no subscribers, on entering that state, and, in a clustered queue, a publish with no server to publish to at all.
- In a clustered queue: round-robin having no available instance left, and a joining server failing to start or to subscribe, with the host and the phase.
No control flow, return value, redis round-trip or timer was altered, and no new public API was added. One deliberate difference: the line reporting a worker key that could not be deleted is now written through a contained writer, so a logger which itself throws can no longer surface that throw as an unhandled rejection — every line of this change is required to be unable to influence queue behaviour.
A failure code is never taken from the error as it is: only an allow-listed code is printed — an
IMQ_-prefixed framework code, a systemE…code, a small integer, a known redis reply code (WRONGTYPE,NOSCRIPT,LOADING, …) or one of a few known redis-client failure messages mapped to codes of our own. Everything else, including the error's message, stack and class name, is reported asunknown. - A write to redis rejected inside
-
Starting a queue overwrote the redis keyspace-notification configuration.
notify-keyspace-eventsis a server-global setting, and the watcher owner set it to the literalExon every connection establishment. Any other flag — enabled by an operator through a config file or by other code sharing the same redis — was silently dropped, breaking every consumer that relied on it, and coming back after each reconnect.The current value is now read first, only the missing
E/xflags are appended, andCONFIG SETis skipped entirely when the configuration already suffices.Ais recognised as coveringx, so a server onAKonly gainsE. WhenCONFIGis unavailable (e.g. AWS ElastiCache) the read fails and the configuration is left untouched — enablenotify-keyspace-eventsout of band there, as before.
-
One transient sweep failure permanently disabled safe-delivery lease recovery and the cleanup sweep.
processWatch()clearedsafeCheckIntervalin its catch block, whilewatch()re-arms that interval only once per watcher connection, guarded by a__ready__flag the teardown did not reset. A single network blip,CLUSTERDOWNor slow failover therefore disabled both for that connection's whole lifetime.Nothing looked broken when it happened: the queue kept accepting and delivering messages, and only messages abandoned by dead workers quietly stopped coming back. Recovery needed the watcher connection replaced, or another instance winning the lock.
A failed sweep now abandons only that sweep, and the next tick retries. The legitimate teardowns are unchanged —
runSafeCheck()still clears the interval when the writer is gone, anddestroy()clears it on shutdown. -
The
./debugexport subpath could never resolve. It pointed at./debug.d.tsand./debug.js, neither of which exists in the repository or in any published tarball, and nothing in core, rpc or the website imports it. Removed. Not a breaking change: resolving it failed before withMODULE_NOT_FOUNDand fails now withERR_PACKAGE_PATH_NOT_EXPORTED, so nothing that worked stops working — only the error code differs.
-
IMessage.delayandIMQOptions.verboseExtendedare inert, and now say so.send()builds the wire packet as{ id, message, from }andprocess()reads back the same three, soIMessage.delayis never written and never read — a delay lives in the delayed sorted set, not in the envelope. Nothing anywhere readsIMQOptions.verboseExtended, so setting it produces no extra output at all, though the published reference claimed it enabled extended verbose logging.Deprecated rather than removed because removal is type-level breaking, and inconsistently so: an inline object literal at the call site would stop compiling while the same option in an inferred config variable would not, so two users with identical intent would get different outcomes from one update. Both go in the next major.
-
A destroyed queue could swallow one message addressed to the next owner of its queue name. The reader is the only channel issuing blocking reads (
BRPOP/BLMOVEwith an infinite timeout), and redis cannot process aQUITwhile one is in flight. The graceful quit therefore never completed, and the connection stayed a registered consumer of the queue until theIMQ_CONNECTION_QUIT_TIMEOUTgrace period expired — outlivingdestroy(). Any message pushed to that key in the meantime went to the dead reader, whose read loop was already torn down, and was dropped with no error anywhere.The reader is now disconnected immediately, which unregisters it as a consumer without consuming anything. Idle channels are still quit gracefully, with the same forced-disconnect fallback as before.
This is reachable whenever a queue name is handed over, which is exactly what
@imqueue/rpcdoes when a client is destroyed and a new one takes its identifier — including from another process, where nothing on the rpc side can prevent it.