Skip to content

feat(sentinel): emit connect/ready/reconnecting/end lifecycle events - #3430

Open
nkaradzhov wants to merge 14 commits into
redis:masterfrom
nkaradzhov:feat/sentinel-lifecycle-events
Open

feat(sentinel): emit connect/ready/reconnecting/end lifecycle events#3430
nkaradzhov wants to merge 14 commits into
redis:masterfrom
nkaradzhov:feat/sentinel-lifecycle-events

Conversation

@nkaradzhov

@nkaradzhov nkaradzhov commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

This pull request resolves #3012 by making RedisSentinel emit the connect, ready, reconnecting, and end lifecycle events, matching the events the standalone client already provides.

Background

RedisSentinel exposed isOpen/isReady but never emitted lifecycle events — only error and topology-change. Applications therefore had no event-driven way to observe sentinel startup, failover, or shutdown, which #3012 asked for. This also supersedes #3276 (abandoned), and follows the review direction given there.

What changed

  • The events are wired to the internal state transitions through #setOpen/#setReady setters (single source of truth), instead of scattering emit(...) calls across connect()/close()/destroy():
    • #setOpen(true|false)connect / end
    • #setReady(true)ready
    • #setReady(false) while still open and not tearing down → reconnecting
  • #reset() (the reconfigure/failover path) now drops readiness (reconnecting) and restores it once the new topology is connected (ready).
  • The public RedisSentinel facade forwards the new events from the internal emitter (it previously forwarded only error/topology-change).
  • Docs: added an Events table to docs/sentinel.md, including the divergences from the standalone client.

Behavior notes

  • end fires at most once, even if both close() and destroy() are called (the setter guards on the actual transition).
  • On a failover the sentinel emits reconnecting once while it reconfigures and ready again afterward; it does not surface the per-socket reconnect churn of the underlying master/replica node clients (the facade tracks its own state, not their sockets).
  • During a reconfigure isReady is briefly false. This flag is not read on the command-dispatch path, so command routing is unchanged; it only affects the #reset re-entry guard and the public isReady getter (which now correctly reads false mid-reconfigure).
  • On a failed reconfigure, readiness is restored without emitting ready, so background resets can keep retrying (matches prior behavior where isReady stayed effectively usable across a failed background reset).

Tests

  • packages/client/lib/sentinel/lifecycle-events.spec.ts — deterministic connectreadyend, destroy() path, and end-fires-exactly-once across repeated close()/destroy().
  • packages/client/lib/sentinel/index.spec.ts — a framework-backed failover test asserting reconnecting then re-ready after the master is stopped.

Note: the test suite requires Docker (real Redis + Sentinel containers) and was not run in my local environment; CI should exercise it.

Closes #3012.

🤖 Generated with Claude Code


Note

Medium Risk
Touches connect/close/destroy and failover reconfigure paths with reentrant listener edge cases; behavior change is intentional but apps relying on old silent isReady during scans may now observe events.

Overview
RedisSentinel now emits connect, ready, reconnecting, and end (plus existing error / topology-change), aligned with standalone client ergonomics for #3012.

Lifecycle is driven by #setOpen / #setReady on the internal layer: the public sentinel forwards those events; reconnecting fires when readiness drops during a real master change (not idle scans), then ready again after reconfigure. end is deduped across repeated close()/destroy().

Connect/teardown behavior is tightened for event listeners and reserveClient: coalesced #teardownPromise, guarded #connectPromise clearing for reentrant connect() from end, #destroy cleared before end so reopen works, #releaseReservedLease() in close()/destroy() finally, and #reset() gated on #isOpen so failed failovers can retry.

Docs add an Events section in docs/sentinel.md (including differences from the standalone client). Tests add lifecycle-events.spec.ts and a Docker failover case for reconnectingready.

Reviewed by Cursor Bugbot for commit e3aec96. Bugbot is set up for automated code reviews on this repo. Configure here.

RedisSentinel exposed isOpen/isReady but never emitted the lifecycle
events the standalone client provides, so applications could not observe
sentinel startup, failover, or shutdown via events (only `error` and
`topology-change` were emitted).

Wire the events to the internal state transitions via #setOpen/#setReady
setters (single source of truth) rather than scattering emits through
connect/close/destroy: `connect`/`end` track isOpen, `ready` tracks
isReady, and a readiness drop during a reconfigure emits `reconnecting`.
The setters make `end` fire at most once across repeated close()/destroy()
and let `reconnecting`/re-`ready` fall out of the failover path (#reset).
The public facade forwards the new events from the internal emitter.

Closes redis#3012. Supersedes redis#3276 (abandoned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nkaradzhov
nkaradzhov requested a review from PavelPashov August 26, 2026 10:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b235285fce

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread docs/sentinel.md Outdated
Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread packages/client/lib/sentinel/index.ts
…cle events

Addresses automated review of the lifecycle-events change:

- reconnecting: drive the readiness drop from transform() only when the master
  actually changes (analyze() leaves masterToOpen undefined otherwise), instead
  of from every #reset(). A healthy periodic scan (scanInterval) no longer emits
  a spurious reconnecting/ready cycle.
- coalescing: revert #reset() to leave #isReady untouched and check the in-flight
  #connectPromise BEFORE the readiness gate, so a control event arriving during a
  reconfigure still registers via #anotherReset (no dropped topology update).
- spurious ready: #setReady(true) no-ops while #destroy is set, so an in-flight
  connect aborted by close()/destroy() cannot emit ready after end.
- re-entrancy: assign #connectPromise before emitting connect, so a listener that
  calls close()/destroy() from the event awaits the in-flight attempt.
- failed reconfigure: restore #isReady silently so later control events can retry.
- docs: distinguish sentinel-level error (may be a string) from client-error, and
  note reconnecting fires only on a real master change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3db3a4a83e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread docs/sentinel.md Outdated
Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment thread packages/client/lib/sentinel/index.ts
Comment thread packages/client/lib/sentinel/index.ts
Comment thread packages/client/lib/sentinel/index.ts
…docs

Second round of automated review:

- Gate #reset() on #isOpen instead of #isReady and drop the silent readiness
  restore. A failed reconfigure now honestly stays not-ready (no false isReady,
  no dangling reconnecting) while later control events can still retry and
  re-emit ready, because the gate no longer depends on readiness.
- docs: client-error is emitted only on the internal and is not forwarded to the
  public sentinel, so document that underlying-client errors reach `error` only
  when passthroughClientErrorEvents is true (removed the misleading client-error row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f1315a0b2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/sentinel/index.ts Outdated
Third round of automated review:

- close() never cleared #destroy (only destroy() did), so a later connect()
  hit #connect()'s teardown guard and returned immediately — emitting `connect`
  but never `ready`, leaving isOpen=true with no topology. close() now clears
  #destroy at the end, mirroring destroy().
- Clear #destroy BEFORE emitting `end` (via #setOpen) in both close() and
  destroy(), so a reentrant connect() from an `end` listener sees teardown
  finalized and reopens cleanly instead of landing in a half-open state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@UgaTheDev

Copy link
Copy Markdown
Contributor

Took this one apart on a live sentinel deployment rather than by reading, since the whole value of the PR is the event ordering and that is exactly what reading can't confirm. Returning the favour for the review on #3428. Short version: the design holds up under every reentrancy and ordering case I could construct, including a real failover. One test-coverage suggestion and one doc nit, both non-blocking.

The structural claim checks out

#setOpen/#setReady really are the single source of truth, and that is verifiable rather than conventional — grepping every write and every emit in packages/client/lib/sentinel/index.ts on this head:

  • the only assignments to #isOpen/#isReady are lines 797 and 813, both inside the setters;
  • the only emit sites for the four events are those two setters, plus the four forwarders at lines 372–375.

So there is no path that can emit ready without going through the readiness transition, and no path that can flip state without emitting. That is the property the PR needed and it is actually enforced, not just documented.

Executed evidence

Environment: real master 7000 + replica 7001 + sentinels 7100/7101/7102 (quorum 2, down-after 1000ms), Redis 8.4.0, driven through the public createSentinel(). Failovers triggered by SHUTDOWN NOSAVE on the live master.

# Case Observed sequence Result
1 connect() then close() connect,readyconnect,ready,end
2 close(), close(), destroy() connect,ready,endend count = 1
3 close() then connect() again connect,ready,end,connect,ready
4 destroy() with no prior connect() [] — nothing emitted
5 connect() against a dead sentinel connect,end, throws, no ready, isOpen=false
6 destroy() from inside the connect listener connect,end, no ready after end, settles isOpen=false isReady=false
7 Real failover — killed master 7000 connect,ready,reconnecting,ready; reconnecting count = 1; master 70007001; isReady=true
8 scanInterval: 500, stable topology, 4s [] — no events across 7 confirmed scan cycles
9 close() from inside the reconnecting listener, mid-real-failover connect,ready,reconnecting,end, no ready after end, settles isOpen=false isReady=false

Plus: npm run test:types (tsc) clean, and the same probe run on master emits zero events, confirming the change is purely additive with no double-emission against pre-existing behaviour.

A few of these are worth calling out specifically, because they're the ones the bots were worried about:

#8 is non-vacuous. "No events during healthy scans" passes trivially if the scan timer never fires, so I attached the built-in tracer and counted actual reconfigure cycles: 7 starting connect loop cycles in the 4s window, zero events emitted. Driving reconnecting from transform()'s masterToOpen branch instead of from #reset() genuinely fixes the "false failover every scan tick" problem — it isn't just moved somewhere quieter.

#7 is the one I most wanted to see fail and didn't. Exactly one reconnecting per failover episode, even though #connect() can loop several times when #anotherReset is set — the second #setReady(false) is a no-op because readiness is already false, so the loop can't produce a stutter of reconnecting events. ready lands only after the new master is actually connected.

#6 and #9 confirm the reentrancy reasoning. Assigning #connectPromise before #setOpen(true) emits connect, and emitting reconnecting from inside transform() (already within an assigned #connectPromise), both mean a listener that tears down from within the event awaits the in-flight attempt. In both cases the facade settles fully closed with no ready after end — which was the failure mode worth guarding against.

#3 confirms the close() fix. Clearing #destroy before #setOpen(false) makes the sentinel genuinely reopenable; connect() after close() re-emits both connect and ready rather than the connect-without-ready half-open state.

One suggestion: the reopen path has no test

Case #3 above is the one behaviour change in this PR with no test covering it, and it's also the subtlest: close() clearing #destroy before #setOpen(false) is pure ordering, with nothing in the type system or the existing suite to hold it in place. A future refactor that moves that line back below the #setOpen(false) call — or that "tidies up" by removing the now-apparently-redundant #destroy = false from close() — reintroduces the exact connect-without-ready state, silently, with the whole suite still green.

It's cheap to pin, in the same style as the existing cases in lifecycle-events.spec.ts:

testUtils.testWithClientSentinel('reopens after close(): connect+ready emitted again', async sentinel => {
  const events: Array<string> = [];
  sentinel
    .on('connect', () => events.push('connect'))
    .on('ready', () => events.push('ready'))
    .on('end', () => events.push('end'))
    .on('error', () => { });

  await sentinel.connect();
  await sentinel.close();
  await sentinel.connect();
  assert.deepEqual(events, ['connect', 'ready', 'end', 'connect', 'ready']);

  await sentinel.destroy();
}, OPEN);

That's the exact sequence I observed on this head, so it should go green as-is.

One doc nit

The Divergence from the standalone client paragraph covers the reconnecting/scan semantics well, but doesn't mention what a failed connect() looks like. Because connect is emitted when the attempt starts rather than when the topology is discovered, a connect() that gives up emits connect followed by end and then rejects (case #5) — whereas the standalone client, which emits connect on actual socket connect, emits nothing at all. Anyone driving a health check or a readiness gauge off these events will hit that, and it's the one place where "sentinel connect ≠ client connect" has a visible consequence. One sentence in the note that's already there would cover it.

Checked, nothing to do

The four new events aren't typed on the public interface — but RedisSentinel (index.ts:292), RedisClient (client/index.ts:357) and RedisCluster (cluster/index.ts:195) are all bare extends EventEmitter, so the sentinel is consistent with the rest of the codebase and typing it here alone would be the odd one out. Not something for this PR. RedisSentinelClient isn't an EventEmitter at all, so there's no second surface needing the same forwarding.

Test harness usage is also correct: disableClientSetup (packages/test-utils/lib/index.ts:722) skips both the auto-connect() and the auto-teardown finally, and all three new cases clean up after themselves (close(), destroy(), destroy()), so nothing leaks.

Nice, tightly-scoped change — the decision to route every transition through two setters is what makes it auditable, and the deferrals on reserveClient and logical-master-identity look like the right calls for keeping this PR reviewable. LGTM.

nkaradzhov and others added 2 commits August 27, 2026 15:24
close() must clear #destroy before #setOpen(false); nothing else in the
suite or type system holds that ordering in place. Covers connect() ->
close() -> connect() re-emitting the full connect/ready pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unlike the standalone client (connect = established socket, emits
nothing on a failed attempt), the sentinel emits connect at the start
of the attempt, so a failed connect() emits connect followed by end
before rejecting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abde08a4da

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Clear #destroy before emitting `end` (via #setOpen) and before returning, so a
// later connect() — or a reentrant connect() from an `end` listener — is not left
// half-open by #connect()'s teardown guard. Mirrors destroy().
this.#destroy = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the reserved lease when reopening

When reserveClient: true, the first connection removes a lease from the master queue and stores it in #reservedClientInfo, which is not cleared by close(). Since clearing #destroy here now makes reopening supported, a subsequent connect() unconditionally requests another lease; with the default one-client master pool, none is available and the second connect() hangs after the internal client has already emitted ready. Reuse the existing reservation or release and clear it during teardown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 67b0305: teardown now releases the reserved lease, so reopening with reserveClient no longer hangs. Test added.

Comment on lines +1279 to +1280
this.#destroy = false;
this.#setOpen(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize overlapping teardown calls before reopening

When two close() calls overlap, the first can be awaiting its node-client close promises after clearing the client arrays, while the second sees empty arrays, reaches this assignment immediately, and emits end. A caller awaiting the second close can then reconnect, but when the first close eventually resumes it destroys the shared PubSub proxy and calls #setOpen(false), leaving the reopened facade closed while its newly created master clients remain live. Track and await a single teardown operation, or otherwise keep the teardown guard active until every overlapping close has completed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — real but needs overlapping close() calls plus an instant reconnect; serializing teardown changes destroy-during-close semantics, so deferring to a follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisited after a second independent confirmation — fixed in db5749a: close()/destroy() join a single in-flight teardown promise. Test covers close+destroy racing with a reconnect from the end listener.

Comment thread docs/sentinel.md Outdated

> :warning: You **MUST** listen to `error` events. Without at least one `error` listener, an emitted error is thrown and crashes the process.

**Divergence from the standalone client:** these events track the sentinel facade's own state, not the sockets of the individual master/replica node clients. `reconnecting` is emitted only when the monitored master actually changes (a failover) — routine periodic topology scans that find no change are silent — and `ready` is re-emitted once the new master is connected; the per-socket reconnect churn of the underlying node clients is not surfaced. Because the sentinel abstracts failover away, a `connect`/`ready` pair is emitted once on the initial `connect()`, and `end` fires at most once even if `close()` and `destroy()` are both called. Also note that `connect` marks the start of the connection attempt (not an established socket), so a `connect()` that fails emits `connect` followed by `end` before rejecting — whereas the standalone client emits nothing in that case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the standalone failed-connect comparison

The standalone client does not always emit nothing when connect() fails: if the TCP/TLS socket connects but initialization subsequently fails, such as an invalid AUTH or SELECT, RedisSocket.#connect() emits connect before awaiting the initiator and its public connect() can still reject afterward. This statement therefore gives users an incorrect event contract; qualify it to failures that happen before the socket is established or describe the initialization-failure case separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b5bfc9b: standalone emits nothing only when the socket never connects; connect still precedes an init-failure rejection.

// close()/destroy() from within the event awaits the in-flight attempt instead
// of tearing down before it starts. Readiness (and `ready`) is set by #connect().
this.#connectPromise = this.#connect();
this.#setOpen(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Await discovery cleanup when a connect listener throws

When a public connect listener throws, this synchronous call throws after #connectPromise has already started observe(). The catch path clears the field before calling destroy(), so teardown cannot await that still-running discovery and resets #destroy to false; the abandoned #connect() can then resume, create persistent sentinel/master clients, and emit ready after end even though the public connect() rejected. Keep the in-flight promise reachable and cancel or await it before completing teardown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 60a1d4b: destroy() now awaits the in-flight attempt, so a throwing connect listener settles closed — no ready after end. Test added.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit abde08a. Configure here.

Comment thread packages/client/lib/sentinel/index.ts
nkaradzhov and others added 3 commits August 27, 2026 16:22
The master client queue is only filled at construction. With
reserveClient: true and the default one-client pool, connect() took the
only lease and close()/destroy() never returned it, so a reopening
connect() waited forever in getClientLease().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ct listener throws

connect()'s catch cleared #connectPromise before calling destroy(), so
when a `connect` listener threw, teardown could not await the
still-running discovery. That attempt then resurrected clients and
emitted `ready` after `end` with the facade reporting isOpen=false.
The finally already clears #connectPromise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standalone client emits `connect` on an established socket, so it
does emit `connect` before rejecting when initialization (e.g. AUTH)
fails after the socket is up; only a connect() that never reaches the
server emits nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b5bfc9b79f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment on lines +573 to +575
async close() {
return this._self.#internal.close();
await this._self.#internal.close();
this._self.#releaseReservedLease();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the reserved lease when teardown listeners throw

When reserveClient: true, an end listener that throws makes #internal.close() reject after the internal teardown has completed, so execution never reaches #releaseReservedLease(); destroy() has the same sequencing. The Sentinel can then be reopened, but connect() waits forever for the only default pool slot because the old reservation was never returned. Run the lease release in a finally block so listener exceptions cannot strand it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f61e80c: the lease release runs in a finally, so a throwing end listener cannot strand it. Test added.

Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment on lines +825 to +829
this.#isReady = value;
if (value) {
this.emit('ready');
} else if (this.#isOpen && !this.#destroy) {
this.emit('reconnecting');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate lifecycle listener failures instead of retrying

When a newly added ready or reconnecting listener throws, #setReady has already changed the state and its emit() is still inside #connect()'s topology-retry try block. The exception is therefore treated as a discovery failure; after the default one-second delay, the next attempt skips the same event because the state already changed and succeeds, silently swallowing the listener exception while performing an unnecessary rediscovery. Emit these lifecycle events outside the topology retry boundary, or otherwise distinguish listener failures so they reach the initiating caller/background error path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1819e9d: listener exceptions from these emits route to error instead of masquerading as discovery failures inside the retry loop. Test added.

nkaradzhov and others added 4 commits August 28, 2026 11:49
…hrows

A throwing `end` listener makes the internal close()/destroy() reject
after teardown has completed, skipping the lease release and reviving
the reopen hang with reserveClient. Run the release in a finally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…empt's finally

connect()'s and #reset()'s finally blocks cleared #connectPromise
unconditionally. An `end` listener that reconnects during the failed
attempt's teardown re-assigns the field, and the clobber left the new
discovery untracked — close()/destroy() no longer awaited it, so it
could leak clients and emit `ready` after `end`. Clear only when the
promise is still our own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rror event

These emits fire inside #connect()'s topology-retry loop, so a throwing
listener was treated as a discovery failure: a pointless one-second
rediscovery, and the exception silently swallowed because the state had
already flipped. Route listener exceptions to `error` instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A second teardown pass over already-emptied client arrays finished
early, emitted `end` while the first pass was still awaiting its
client-close promises, and the first pass's tail then destroyed the
pub/sub proxy and flipped isOpen on a sentinel that an `end` listener
had since reopened. Both methods now join a single in-flight teardown
promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db5749a853

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/sentinel/index.ts Outdated
Comment on lines +1335 to +1337
this.#teardownPromise ??= this.#doDestroy().finally(() => {
this.#teardownPromise = undefined;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down reentrant connections instead of joining old teardown

When an end listener starts connect() and a later end listener—or the nested connect handler—calls destroy(), #teardownPromise still refers to the teardown currently emitting end. This ??= therefore returns that nearly completed promise without running #doDestroy() against the new connection; after the old teardown finishes, the reentrant attempt can create clients and emit ready, so destroy() has resolved while the sentinel remains open. Distinguish teardown of the reopened generation from overlapping calls targeting the old one.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e3aec96: #teardownPromise is cleared before end is emitted (new generation) and the wrapper's finally is identity-guarded, so a destroy() from inside the emit tears down the reentrant reopen instead of joining the finished pass. Test added.

#teardownPromise was cleared only after `end` listeners ran, so a
destroy() called from inside the emit — after an `end` listener had
reentrantly reconnected — joined the nearly-finished old teardown and
resolved without touching the reopened sentinel, which went on to emit
`ready`. Clear the promise before the emit so such calls start a fresh
teardown against the new generation, and identity-guard the wrapper's
finally so it cannot wipe that newer generation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3aec96671

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1344 to +1345
async destroy() {
if (this.#teardownPromise === undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let destroy preempt an in-flight graceful close

When close() is already waiting for pending commands, a subsequent destroy() reaches this method with #teardownPromise set and merely returns the graceful-close promise instead of invoking #doDestroy(). The underlying RedisClient.close() explicitly waits for its command queue to empty, so a blocking command with commandOptions.timeout: undefined can keep both calls pending indefinitely even though destroy() is expected to reject commands and terminate immediately. Preserve teardown serialization while allowing a destructive request to upgrade or cancel an in-progress close.

Useful? React with 👍 / 👎.

Comment on lines +843 to +844
} catch (err) {
this.emit('error', err);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep unhandled lifecycle errors out of topology retries

Fresh evidence in the current code is that the replacement error emission is still inside #connect()'s topology-retry boundary: when a ready or reconnecting listener throws and the public sentinel has no error listener, this line throws through the forwarding handler, the outer retry loop catches it as a discovery failure, and the next iteration skips the lifecycle event because #isReady was already changed. The initiating operation can therefore succeed after a one-second delay while silently swallowing the exception, contrary to the documented unhandled-error behavior; dispatch or propagate this error outside the topology retry path.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sentinel Client: Add connect/ready/end events

2 participants