Skip to content

fix(cluster): retry a member that refused a live registration - #33

Merged
Mikhus merged 3 commits into
masterfrom
fix/retry-refused-live-registration
Sep 18, 2026
Merged

Mikhus merged 3 commits into
masterfrom
fix/retry-refused-live-registration

Conversation

@Mikhus

@Mikhus Mikhus commented Sep 18, 2026

Copy link
Copy Markdown
Member

Follow-up to #32, closing the half of #31 it left open.

#32 retries a joining host whose subscription catch-up failed. The same failure reached through a live registrationsubscribe() on a cluster that already has members — still ended in a host that was connected, ready and subscribed to nothing for the life of the process. A statically configured cluster takes no other path: its hosts are added without a catch-up, so every registration reaches them live.

The application cannot repair this itself. A rejected subscribe() is documented as not retryable, because the registration stays remembered and a second call registers a duplicate on every host that did accept it.

Against a real broker behind a port that refuses first and then answers, measured 9 s after the address came up:

scenario 3.5.2 after #32 this PR
subscribe, then the host joins silent repaired repaired
static member down at subscribe time silent silent repaired
host joins, subscribe while it still refuses silent silent repaired

"silent" is a connected subscription socket, zero subscribers reported by redis, and a handler that never fires.

The change

The live path hands a refusing member to the same scheduleSync() the join uses, and nothing else is new. The per-host timer guard already stops a second schedule while a retry is pending, and that retry installs the whole missing suffix, so a registration arriving meanwhile is covered by it. A join's announcement cannot be displaced: its catch-up is enqueued on the host's chain in the same synchronous step that makes the host a member, so its retry is always scheduled before a live registration's can be. scheduleSync()'s startup and announce arguments become optional, since a member has nothing to announce.

subscribe() still rejects, so the caller learns a host was unreachable when it subscribed. It no longer means that host stays unsubscribed. The log line, the doc-blocks and CHANGELOG.md say so.

Tests

Three commits, each green on its own.

  • test(cluster): the four copies of the refusing subscribe mock become one refusing() helper, ahead of the fifth spec that needs it. 418 specs before and after.
  • fix(cluster): one unit spec pins the live wiring, and nothing in it touches scheduleSync. The suffix spec loses the manual scheduleSync() call it needed to get the same effect, which is what showed the wiring was missing. One integration spec drives a real broker through a statically configured member that refuses and then answers, and asks redis itself for the subscriber, since the live path announces nothing.
  • test(queue): pins the listener reconcile from fix(cluster): retry a joining host whose subscription catch-up failed #32. Removing removeAllListeners('message') left every unit spec green; only the integration spec saw it, and CI has no broker. The new spec subscribes, runs the restore again on the same connection, and asserts one listener and one delivery. Against a real broker the race is not rare: without the reconcile, 18 of 20 runs of the join repair spec delivered the payload twice.

How it was verified

npm test: 420 pass, three runs. npm run test-integration against a real broker: 22 pass, none skipped, and both repair specs 10 of 10. npm run lint and npm run format:check clean.

Each new spec was checked by mutation. Removing the live wiring fails both unit specs and times the integration spec out. Removing the reconcile line fails the new queue spec on a listener count of 2.

…p specs

Four specs each carried their own copy of the same mock: a subscribe() that
refuses until a flag flips and then records the handler at completion, as
RedisQueue.subscribe does. A fifth spec that needs it is about to land, so the
copies become one `refusing()` helper next to the fake host it complements. It
takes the target, which is a single host or RedisQueue.prototype for every
host, and hands back the gate and the mock.

No behaviour under test changes: 418 specs before and after.
The retry added for #31 was wired to the join only. subscribe() on a cluster
that already has members fans syncHost() out to them with nothing behind it,
so a member that refused its first subscribe ended exactly where a joining host
used to: RedisQueue.subscribe() recorded no handler, the connection layer
reconnected and restored a socket subscribed to no channel, and nothing asked
again. A statically configured cluster takes no other path, because its hosts
are added without a catch-up, so for it the defect in #31 was still fully open.

The application could not repair it either. A rejected subscribe() is
documented as not retryable: the registration stays remembered, and a second
call registers a duplicate on every host that did accept it.

Against a real broker behind a port that refuses first and then answers,
measured 9 s after the address came up:

  scenario                                  3.5.2    after #32   now
  subscribe, then the host joins            silent   repaired    repaired
  static member down at subscribe time      silent   silent      repaired
  host joins, subscribe while it refuses    silent   silent      repaired

"silent" is a connected subscription socket, zero subscribers reported by
redis, and a handler that never fires.

The live path now hands a refusing member to the same scheduleSync() the join
uses. Nothing else is new. The per-host timer guard already stops a second
schedule while a retry is pending, and that retry installs the whole missing
suffix, so a registration arriving meanwhile is covered by it. A join's
announcement cannot be displaced: its catch-up is enqueued on the host's chain
in the same synchronous step that makes the host a member, so its retry is
always scheduled before a live registration's can be. scheduleSync()'s startup
and announce arguments become optional, since a member has nothing to
announce. subscribe() still rejects, so the caller learns a host was
unreachable when it subscribed; it no longer means that host stays
unsubscribed. The log line and the doc-blocks say so.

One unit spec pins the wiring, and nothing in it touches scheduleSync. The
suffix spec loses the manual scheduleSync() call it needed to get the same
effect, which is what showed the wiring was missing. One integration spec
drives a real broker through a statically configured member that refuses and
then answers, and asks redis itself for the subscriber, since the live path
announces nothing. Removing the wiring fails both unit specs and times the
integration spec out.
restoreSubscription() clears the connection's `message` listeners before it
re-attaches the remembered handlers. That is what stops a subscribe() racing a
reconnect from leaving its handler attached twice and every message delivered
twice. Removing that one line left every unit spec green. The only spec that
saw it was the integration one, and CI has no broker, so there it is skipped
and the line was unguarded.

The race does not need a broker to be pinned, only its end state: a connection
that already carries the handler when the restore runs. The spec subscribes,
runs the restore again on the same connection, and asserts one listener and one
delivery. With the line removed it fails on a listener count of 2.

Against a real broker the race is not rare: without the reconcile, 18 of 20
runs of the join repair spec delivered the payload twice.
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the @imqueue Contribution Terms. ✅
Posted by the CLA Assistant Lite bot.

@Mikhus

Mikhus commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

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

github-actions Bot added a commit that referenced this pull request Sep 18, 2026
@Mikhus
Mikhus merged commit fb8959f into master Sep 18, 2026
11 of 12 checks passed
@Mikhus
Mikhus deleted the fix/retry-refused-live-registration branch September 18, 2026 10:51
@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.

1 participant