fix(cluster): retry a joining host whose subscription catch-up failed - #32
Merged
Merged
Conversation
A host whose first subscribe() rejected was left without subscription handlers for the life of the process, while every probe it answered reported health. RedisQueue.subscribe() records a handler only after chan.subscribe() resolves, so a first failure leaves subscriptionHandlers empty. The connection layer then reconnects normally, but restoreSubscription() returns early on an empty list - and returns before re-subscribing - so the replacement socket is connected, ready, and subscribed to nothing. Nothing brought that host back: the joining catch-up rejection was swallowed and left progress.installed at 0, start() fans out startHost only, a re-announced address is recognised as a known server and skipped, and no event reports the failure. A service that subscribes once at start-up never registers again, so the message promising repair "until a later registration triggers another catch-up" never came true. send() is unaffected throughout, which is what makes it hard to spot. The join site now retries the subscription leg with capped exponential backoff, cancelled when the host leaves the cluster or the cluster is destroyed, on an unref()ed timer so a host that never returns cannot hold the process open. Only that leg is retried: a failed start() is already retryable through an explicit start(), and retrying it here would poll a connection object the reconnect path owns. Retrying is safe because catch-up is idempotent - syncHost() reads the cluster-owned installed count inside the per-host chain and installs only the missing suffix, so a retry cannot reinstall a handler that already landed. The backoff mirrors the connection layer's own policy, which this has to outlive. A host that becomes usable only through a retry is announced as initialized once its startup has also succeeded and while it is still a member. Sends parked in sendWhenInitialized() wait on that event, so without it they would time out against a cluster that had recovered. The end-to-end test for this found a second defect. connect() binds a connection before awaiting it and returns that same object to a concurrent caller, so a subscribe() racing a reconnect attached its handler to a connection whose restore had not yet run; restoreSubscription() then re-attached every remembered handler to the same connection and every message was delivered twice. The race predates this change - any live subscribe() during a reconnect could hit it - but the retry and the reconnect share a 1s base delay, which made it land in 3 of 30 runs. restoreSubscription() now reconciles the connection's message listeners to exactly the remembered handlers instead of appending to them. Both orders of the race end with one listener per handler, and a deliberate double registration of the same function still fires twice. syncHost()'s failure message is updated accordingly: a joining host now retries on its own, while a failure during a live registration is still repaired by the next one. Five unit tests cover the join-site wiring, the retried install, suffix-only installation, cancellation on removal, and the parked send; each was calibrated by mutation. One integration test drives a real broker behind a proxy that refuses the first connection and asserts exactly one delivery after recovery; it fails 3/3 on the unmodified source, delivered twice in 3/30 runs with the retry alone, and is green 20/20 with both changes. 418 unit tests pass. Closes #31
Member
Author
|
I have read the CLA Document and I hereby sign the CLA |
Gabriellji
marked this pull request as draft
September 17, 2026 11:20
Gabriellji
marked this pull request as ready for review
September 17, 2026 11:44
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Closes #31
A server joining a
ClusteredRedisQueueis given the registrations the clusterhas remembered so far. If the first of those installs rejected, that host was
never subscribed again for the life of the process.
RedisQueue.subscribe()records a handler only afterchan.subscribe()resolves, so a first failure left
subscriptionHandlersempty — and everyrecovery path replays from that array. The connection layer's own reconnect then
succeeded and restored a socket that was connected,
ready, and subscribed tono channel.
start()fans out startup only, a re-announced address isrecognised as a known server and skipped, and no event reported the failure, so
nothing asked again.
send()and RPC over the same broker were unaffectedthroughout, which is what made it hard to see.
The retry
The join now retries the subscription leg with capped exponential backoff,
cancelled when the host leaves the cluster or the cluster is destroyed, on an
unref()ed timer so a host that never returns cannot hold the process open.Only that leg is retried. A failed
start()is already retryable through anexplicit
start(), and retrying it here would poll a connection object thereconnect path owns.
Retrying is safe because catch-up is idempotent:
syncHost()reads thecluster-owned installed count inside the per-host chain and installs only the
missing suffix, so a retry cannot reinstall a handler that already landed. The
public
subscribe()is not used for this — it is documented as non-retryableand would add a duplicate registration. The backoff mirrors the connection
layer's own policy, which this has to outlive: the socket may take several
attempts to come back, and the handlers have to go on afterwards.
A host that becomes usable only through a retry is announced as
initializedonce its startup has also succeeded and while it is still a member. Sends parked
in
sendWhenInitialized()wait on that event, so without it they would time outagainst a cluster that had already recovered. There is exactly one emit site and
the live registration path never announces, so a host is announced once whether
the join, a retry, or a live registration did the repair.
The race the end-to-end test found
With the retry alone, the end-to-end test below delivered the message twice
in 3 of 30 runs. The same handler had been attached twice to one connection —
once by
subscribe(), once byrestoreSubscription().connect()binds a connection before it awaits it and returns that same objectto any concurrent caller. The retry and the connection layer's reconnect share
a 1 s base delay, so they fire in the same timer batch: the reconnect creates
and binds the new connection; the retry's
subscribe()gets it backimmediately, attaches its handler and records it; the reconnect resumes, runs
restoreSubscription(), and re-attaches every remembered handler to the sameconnection.
The race is reachable on 3.5.2 by any live
subscribe()during a reconnect; theretry only made it likely.
restoreSubscription()now clears the connection'smessagelisteners before re-attaching the remembered handlers — reconcile,not append. Both orders end with one listener per remembered handler, and a
deliberate double registration of the same function still fires twice, as
IMessageQueuedocuments.attachandpushinsubscribe()have noawaitbetween them, so a restore cannot land between them and drop a listener.
RedisQueue.tsis the only place that attachesmessageto that connection.A pending-promise guard in
connect()was considered and not taken: it wouldtouch the reader, writer and watcher paths, and the writer is shared across
instances. The listener invariant is the thing that matters, and this protects
it directly.
Changing the retry delay or adding jitter was also rejected: it would only move
the odds.
syncHost()'s failure message is updated to match, andCHANGELOG.mdcarriesboth entries under
[Unreleased] → Fixed.Type of change
Checklist
npm test).How it was verified
npm test: 418 pass. Five unit tests are new — the join-site wiring, theretried install, suffix-only installation, cancellation when a host is removed,
and a send parked on
initializedbeing released once a retried host recovers.Each was calibrated by mutation: the fix was broken in six ways and every one
produced a failure.
scheduleSyncmade a no-opnpm run test-integration: 7 specs against a real broker; one is new. Itputs a TCP proxy in front of redis that is not listening yet, registers on an
empty cluster, adds the proxy as a server and waits for the genuine
ECONNREFUSED, starts the proxy, waits forinitialized, publishes from anindependent
ioredisclient, and asserts that redis reports one subscriber andthat exactly one payload arrives. It skips with a reason where no broker
answers, so CI is unaffected.
Calibrated against the unmodified source on the same harness:
timed out: the repaired host to initialize; the host is neverThe double-delivery mechanism was confirmed by tagging every connection object
and logging each
attachSubscriptionHandlercall: both attaches landed on thesame tag, one from
subscribe, one fromrestoreSubscription.The single-announcement claim was checked directly: a live registration made to
repair the host before the retry fired, five runs,
initializedemitted once ineach.
Known limitations, unchanged by this PR
Retry is unbounded. A host with an address that will never answer, and that
nothing removes from the cluster, retries every 30 s for the life of the
process. A bounded budget was considered and rejected: when it expired the host
would be silently unsubscribed again, which is the defect this fixes. The
connection layer already reconnects indefinitely with the same policy, and every
attempt is logged by
syncHost(), so this is neither new behaviour nor silent.Consumers still cannot observe the state. The failure is not emitted on any
public event, and
RedisQueue.availablereflects the writer connection only, soa subscriber with no subscription still reports available. A host-level health
or failure event would let an application fail its own readiness on this; that
is a separate change.
connect()still returns a connection it has not finished bringing up. Thereconcile makes the subscription path safe against that; a general in-flight
guard remains open.
Contribution terms
I grant the project owner the right to license my contribution
commercially, royalty-free, my contribution stays available under
GPL-3.0, I keep my copyright, and I understand I will receive no fee for
it. If I did not agree, I would not be submitting this contribution.