Commit 4ef9eb3
feat(sentinel): emit connect/ready/reconnecting/end lifecycle events (#3430)
* feat(sentinel): emit connect/ready/reconnecting/end lifecycle events
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 #3012. Supersedes #3276 (abandoned).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sentinel): emit reconnecting only on real failover; harden lifecycle 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>
* fix(sentinel): honest readiness on failed reconfigure; correct error 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>
* fix(sentinel): clear #destroy on teardown so reconnect after close works
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>
* test(sentinel): pin reopen-after-close lifecycle event sequence
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>
* docs(sentinel): note failed connect() emits connect then end
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>
* fix(sentinel): release the reserved client lease on teardown
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>
* fix(sentinel): let destroy() await the in-flight connect when a connect 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>
* docs(sentinel): qualify the standalone failed-connect comparison
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>
* fix(sentinel): release the reserved lease even when an end listener throws
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>
* fix(sentinel): keep a reentrant connect() tracked past the failed attempt'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>
* fix(sentinel): surface throwing ready/reconnecting listeners on the error 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>
* fix(sentinel): serialize overlapping close()/destroy() calls
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>
* fix(sentinel): start a new teardown generation before emitting end
#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>
* fix(sentinel): defer lifecycle-listener errors and let destroy() preempt a blocked close()
A throwing ready/reconnecting listener was routed to `error` synchronously
from inside #connect()'s topology-retry loop; with no `error` listener the
re-emit re-threw and the retry mistook it for a discovery failure, swallowing
it (plus a spurious rediscovery). Defer the routed emit to a microtask so it
escapes the retry boundary and surfaces as documented.
Now that teardown is serialized on a shared #teardownPromise, a destroy()
overlapping an in-flight graceful close() merely joined it — so a close()
blocked draining a command queue made destroy() hang too. Share a
#teardown(kind) coalescer; an overlapping destroy() preempts the close by
force-destroying the draining clients (RedisClient.close() never settles once
destroyed, so #doClose() races the drain against an escalation signal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sentinel): force-terminate draining clients when destroy() preempts close()
The destroy()-preempts-close() escalation force-destroyed clients behind an
`if (client.isOpen)` guard, but a graceful close() has already flipped each
client to isOpen === false while leaving its socket draining — so the guard
skipped exactly the clients that had to be terminated, and RedisSocket.destroy()
would have thrown ClientClosedError on them anyway. The pending blocking
command was never rejected and the socket lingered. The previous test stubbed
close(), so its clients stayed open and never exercised this path.
Make RedisSocket.destroy() force-kill a closing socket (throw only when nothing
remains: not open and no socket), drop the isOpen guard in the sentinel's
force-destroy (swallowing ClientClosedError for a client whose drain already
finished), and replace the stubbed test with a real blocking command that
asserts the command is actually rejected on preemption.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(client): settle close() on forced socket teardown; track reentrant sentinel teardown
Two follow-ups to the destroy()-preempts-close() work:
- RedisClient.close() waited only for a `data` event to confirm the queue had
drained, then tore the socket down itself. When a concurrent destroy() (e.g.
sentinel's escalation) force-kills the draining socket first, no further
`data` arrives, so the close() promise hung forever — leaking the promise and
its listeners. close() now also settles on the socket's `end`, which
destroySocket() emits, without double-tearing-down.
- RedisSentinelInternal#teardown() assigned #teardownPromise only after invoking
#doDestroy()/#doClose(). On an established sentinel #doDestroy() runs to
completion — emitting `end` — before that assignment, so a destroy() started by
an `end` listener registered a new teardown generation that the outer call then
overwrote, leaving it untracked. Register the outer teardown only if a reentrant
one has not already claimed the slot (same identity guard used elsewhere).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>1 parent 3c6a7e8 commit 4ef9eb3
7 files changed
Lines changed: 569 additions & 32 deletions
File tree
- docs
- packages/client/lib
- client
- sentinel
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
88 | 114 | | |
89 | 115 | | |
90 | 116 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1698 | 1698 | | |
1699 | 1699 | | |
1700 | 1700 | | |
| 1701 | + | |
| 1702 | + | |
| 1703 | + | |
| 1704 | + | |
| 1705 | + | |
| 1706 | + | |
| 1707 | + | |
| 1708 | + | |
| 1709 | + | |
| 1710 | + | |
| 1711 | + | |
| 1712 | + | |
| 1713 | + | |
| 1714 | + | |
1701 | 1715 | | |
1702 | 1716 | | |
1703 | 1717 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2206 | 2206 | | |
2207 | 2207 | | |
2208 | 2208 | | |
| 2209 | + | |
| 2210 | + | |
| 2211 | + | |
| 2212 | + | |
| 2213 | + | |
| 2214 | + | |
| 2215 | + | |
| 2216 | + | |
| 2217 | + | |
2209 | 2218 | | |
2210 | 2219 | | |
2211 | 2220 | | |
2212 | 2221 | | |
| 2222 | + | |
2213 | 2223 | | |
2214 | 2224 | | |
2215 | 2225 | | |
2216 | 2226 | | |
2217 | 2227 | | |
| 2228 | + | |
2218 | 2229 | | |
2219 | 2230 | | |
2220 | 2231 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
485 | 485 | | |
486 | 486 | | |
487 | 487 | | |
488 | | - | |
489 | | - | |
490 | | - | |
491 | | - | |
492 | | - | |
493 | | - | |
494 | | - | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
495 | 498 | | |
496 | 499 | | |
497 | 500 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
966 | 966 | | |
967 | 967 | | |
968 | 968 | | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
969 | 996 | | |
970 | 997 | | |
971 | 998 | | |
| |||
0 commit comments