Skip to content

fix(cluster): retry a joining host whose subscription catch-up failed - #32

Merged
Mikhus merged 2 commits into
masterfrom
fix/retry-failed-host-join
Sep 18, 2026
Merged

Mikhus merged 2 commits into
masterfrom
fix/retry-failed-host-join

Conversation

@Gabriellji

Copy link
Copy Markdown
Member

What does this PR do?

Closes #31

A server joining a ClusteredRedisQueue is given the registrations the cluster
has 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 after chan.subscribe()
resolves, so a first failure left subscriptionHandlers empty — and every
recovery path replays from that array. The connection layer's own reconnect then
succeeded and restored a socket that was connected, ready, and subscribed to
no channel. start() fans out startup only, a re-announced address is
recognised as a known server and skipped, and no event reported the failure, so
nothing asked again. send() and RPC over the same broker were unaffected
throughout, 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 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
public subscribe() is not used for this — it is documented as non-retryable
and 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 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 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 by restoreSubscription().

connect() binds a connection before it awaits it and returns that same object
to 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 back
immediately, attaches its handler and records it; the reconnect resumes, runs
restoreSubscription(), and re-attaches every remembered handler to the same
connection.

The race is reachable on 3.5.2 by any live subscribe() during a reconnect; the
retry only made it likely. restoreSubscription() now clears the connection's
message listeners 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
IMessageQueue documents. attach and push in subscribe() have no await
between them, so a restore cannot land between them and drop a listener.
RedisQueue.ts is the only place that attaches message to that connection.

A pending-promise guard in connect() was considered and not taken: it would
touch 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, and CHANGELOG.md carries
both entries under [Unreleased] → Fixed.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Refactor / internal
  • Other:

Checklist

  • I have read the Contributing guide.
  • Tests added or updated, and the full suite passes locally (npm test).
  • Docs / doc-blocks updated where relevant.
  • The PR is focused on a single logical change.

How it was verified

npm test: 418 pass. Five unit tests are new — the join-site wiring, the
retried install, suffix-only installation, cancellation when a host is removed,
and a send parked on initialized being released once a retried host recovers.
Each was calibrated by mutation: the fix was broken in six ways and every one
produced a failure.

mutation result
join site never schedules a retry 1 failure
scheduleSync made a no-op 3 failures
retry ignores cluster membership 1 failure
retry restarts the install from zero 1 failure
retry success never announces parked-send test times out
announce ignores membership 1 failure

npm run test-integration: 7 specs against a real broker; one is new. It
puts 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 for initialized, publishes from an
independent ioredis client, and asserts that redis reports one subscriber and
that 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:

build result
3.5.2 source, test only fails 3/3 — timed out: the repaired host to initialize; the host is never
retry only 3 of 30 runs deliver the payload twice
retry + reconcile 20/20 test runs green; 50/50 runs of the standalone race probe deliver exactly onc

The double-delivery mechanism was confirmed by tagging every connection object
and logging each attachSubscriptionHandler call: both attaches landed on the
same tag, one from subscribe, one from restoreSubscription.

The single-announcement claim was checked directly: a live registration made to
repair the host before the retry fired, five runs, initialized emitted once in
each.

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.available reflects the writer connection only, so
a 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. The
reconcile makes the subscription path safe against that; a general in-flight
guard remains open.

Contribution terms

  • I have read and agree to the @imqueue 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.

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
@Gabriellji

Copy link
Copy Markdown
Member Author

I have read the CLA Document and I hereby sign the CLA

@Gabriellji
Gabriellji marked this pull request as draft September 17, 2026 11:20
@Gabriellji
Gabriellji marked this pull request as ready for review September 17, 2026 11:44
@Mikhus
Mikhus merged commit 18549e9 into master Sep 18, 2026
11 checks passed
@Mikhus
Mikhus deleted the fix/retry-failed-host-join branch September 18, 2026 10:37
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 18, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A host whose first subscribe fails is never subscribed again

2 participants