-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(sentinel): emit connect/ready/reconnecting/end lifecycle events #3430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
b235285
3db3a4a
3f1315a
3d729e7
1b2d822
abde08a
67b0305
60a1d4b
b5bfc9b
f61e80c
4530017
1819e9d
db5749a
e3aec96
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -367,6 +367,13 @@ export default class RedisSentinel< | |
| this.#internal = new RedisSentinelInternal<M, F, S, RESP, TYPE_MAPPING>(options, this.#identity.id); | ||
| this.#internal.on('error', err => this.emit('error', err)); | ||
|
|
||
| /* forward the lifecycle events the internal emits from its open/ready transitions */ | ||
| this.#internal | ||
| .on('connect', () => this.emit('connect')) | ||
| .on('ready', () => this.emit('ready')) | ||
| .on('reconnecting', () => this.emit('reconnecting')) | ||
| .on('end', () => this.emit('end')); | ||
|
|
||
| /* pass through underling events */ | ||
| /* TODO: perhaps make this a struct and one vent, instead of multiple events */ | ||
| this.#internal.on('topology-change', (event: RedisSentinelEvent) => { | ||
|
|
@@ -564,11 +571,31 @@ export default class RedisSentinel< | |
| multi = this.MULTI; | ||
|
|
||
| async close() { | ||
| return this._self.#internal.close(); | ||
| try { | ||
| await this._self.#internal.close(); | ||
| } finally { | ||
| // In a finally: internal teardown completes even when an `end` listener | ||
| // throws, and the lease must not be stranded in that case either. | ||
| this._self.#releaseReservedLease(); | ||
| } | ||
| } | ||
|
|
||
| async destroy() { | ||
| try { | ||
| await this._self.#internal.destroy(); | ||
| } finally { | ||
| this._self.#releaseReservedLease(); | ||
| } | ||
| } | ||
|
|
||
| destroy() { | ||
| return this._self.#internal.destroy(); | ||
| // The reserved lease's slot must go back to the pool queue (it is only filled at | ||
| // construction), otherwise a reopening connect() with reserveClient waits forever | ||
| // for a free client. | ||
| #releaseReservedLease() { | ||
| if (this.#reservedClientInfo) { | ||
| this.#internal.releaseClientLease(this.#reservedClientInfo); | ||
| this.#reservedClientInfo = undefined; | ||
| } | ||
| } | ||
|
|
||
| async SUBSCRIBE<T extends boolean = false>( | ||
|
|
@@ -780,6 +807,44 @@ export class RedisSentinelInternal< | |
| return this.#isReady; | ||
| } | ||
|
|
||
| /** | ||
| * Single source of truth for the `#isOpen` transition. Emits `connect` when the | ||
| * sentinel opens and `end` when it closes, once per real transition, so repeated | ||
| * `close()`/`destroy()` calls fire `end` at most once. | ||
| */ | ||
| #setOpen(value: boolean) { | ||
| if (this.#isOpen === value) return; | ||
| this.#isOpen = value; | ||
| this.emit(value ? 'connect' : 'end'); | ||
| } | ||
|
|
||
| /** | ||
| * Single source of truth for the `#isReady` transition. Emits `ready` when the | ||
| * sentinel becomes ready. A readiness drop while still open and not tearing down | ||
| * (an actual topology reconfigure — see `transform()`) emits `reconnecting`; a | ||
| * drop from `close()`/`destroy()` is silent because `#destroy` is set first. | ||
| */ | ||
| #setReady(value: boolean) { | ||
| if (this.#isReady === value) return; | ||
| // Never flip to ready while tearing down: close()/destroy() set #destroy first, | ||
| // so an in-flight connect that resolves afterwards stays not-ready and does not | ||
| // emit `ready` after `end`. | ||
| if (value && this.#destroy) return; | ||
| this.#isReady = value; | ||
| // Route listener exceptions to `error`: these emits fire inside #connect()'s | ||
| // topology-retry loop, where a throwing listener would otherwise be mistaken | ||
| // for a discovery failure and silently swallowed by the retry. | ||
| try { | ||
| if (value) { | ||
| this.emit('ready'); | ||
| } else if (this.#isOpen && !this.#destroy) { | ||
| this.emit('reconnecting'); | ||
| } | ||
| } catch (err) { | ||
| this.emit('error', err); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| readonly #name: string; | ||
| readonly #sentinelClientId: string; | ||
| readonly #nodeClientOptions: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>; | ||
|
|
@@ -818,6 +883,7 @@ export class RedisSentinelInternal< | |
| } | ||
|
|
||
| #connectPromise?: Promise<void>; | ||
| #teardownPromise?: Promise<void>; | ||
| #maxCommandRediscovers: number; | ||
| readonly #pubSubProxy: PubSubProxy; | ||
|
|
||
|
|
@@ -988,22 +1054,29 @@ export class RedisSentinelInternal< | |
| throw new Error("already attempting to open") | ||
| } | ||
|
|
||
| // Assign #connectPromise before emitting `connect`, so a listener that calls | ||
| // 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(). | ||
| const connectPromise = this.#connect(); | ||
| try { | ||
| this.#isOpen = true; | ||
|
|
||
| this.#connectPromise = this.#connect(); | ||
| await this.#connectPromise; | ||
| this.#isReady = true; | ||
| this.#connectPromise = connectPromise; | ||
| this.#setOpen(true); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a public Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 60a1d4b:
cursor[bot] marked this conversation as resolved.
|
||
| await connectPromise; | ||
| } catch (err) { | ||
| // The initial connect gave up. Tear down whatever was created along the | ||
| // way: clients whose first connection attempt failed keep reconnecting | ||
| // per their `reconnectStrategy`, and would otherwise stay alive in the | ||
| // background (holding sockets and timers) after `connect()` rejected. | ||
| this.#connectPromise = undefined; | ||
| // Keep #connectPromise assigned so destroy() awaits the attempt — it is | ||
| // still in flight when a `connect` listener threw; the finally clears it. | ||
| await this.destroy(); | ||
| throw err; | ||
| } finally { | ||
| this.#connectPromise = undefined; | ||
| // Clear only if still ours: an `end` listener may have started a reentrant | ||
| // connect() whose in-flight promise must stay tracked for close()/destroy(). | ||
| if (this.#connectPromise === connectPromise) { | ||
| this.#connectPromise = undefined; | ||
| } | ||
| if (this.#isReady && this.#scanInterval > 0) { | ||
| this.#scanTimer = setInterval(this.#resetInBackground.bind(this), this.#scanInterval); | ||
| } | ||
|
|
@@ -1027,6 +1100,11 @@ export class RedisSentinelInternal< | |
| continue; | ||
| } | ||
|
|
||
| // Topology is connected: (re)assert readiness. Emits `ready` only on a real | ||
| // transition, so an initial connect and the tail of a failover both emit it, | ||
| // while a healthy periodic scan that changed nothing stays silent. | ||
| this.#setReady(true); | ||
|
|
||
| this.#trace("#connect: returning"); | ||
|
nkaradzhov marked this conversation as resolved.
|
||
| return; | ||
| } catch (e) { | ||
|
|
@@ -1127,22 +1205,35 @@ export class RedisSentinelInternal< | |
|
|
||
| async #reset() { | ||
| /* closing / don't reset */ | ||
| if (this.#isReady == false || this.#destroy == true) { | ||
| if (this.#destroy == true) { | ||
| return; | ||
| } | ||
|
|
||
| // already in #connect() | ||
| // Coalesce with an in-flight connect/reset BEFORE the gate below, so a control | ||
| // event arriving mid-reconfigure still registers via `#anotherReset` and the | ||
| // running `#connect()` re-observes the newest topology. | ||
| if (this.#connectPromise !== undefined) { | ||
| this.#anotherReset = true; | ||
| return await this.#connectPromise; | ||
| } | ||
|
|
||
| // Gate on #isOpen, not #isReady: a failover (or a failed one) may have left | ||
| // readiness false, and later control events must still be able to retry and | ||
| // eventually re-emit `ready`. #isReady is owned by #connect()/transform(), so a | ||
| // failed reconfigure honestly stays not-ready rather than falsely reporting ready. | ||
| if (this.#isOpen == false) { | ||
| return; | ||
| } | ||
|
|
||
| const connectPromise = this.#connect(); | ||
| try { | ||
| this.#connectPromise = this.#connect(); | ||
| return await this.#connectPromise; | ||
| this.#connectPromise = connectPromise; | ||
| return await connectPromise; | ||
| } finally { | ||
| this.#trace("finished reconfgure"); | ||
| this.#connectPromise = undefined; | ||
| if (this.#connectPromise === connectPromise) { | ||
| this.#connectPromise = undefined; | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1174,14 +1265,34 @@ export class RedisSentinelInternal< | |
| this.#resetInBackground(); | ||
| } | ||
|
|
||
| /** | ||
| * Serializes teardown: overlapping close()/destroy() calls join the in-flight | ||
| * teardown instead of running a second pass over already-emptied client arrays — | ||
| * a second pass would emit `end` early and let the tail of the first pass kill a | ||
| * sentinel that a listener has since reopened. | ||
| */ | ||
| async close() { | ||
| if (this.#teardownPromise === undefined) { | ||
| const teardown: Promise<void> = this.#doClose().finally(() => { | ||
| // Guarded like #connectPromise in connect(): an `end` listener may have | ||
| // started a new teardown generation that must not be wiped. | ||
| if (this.#teardownPromise === teardown) { | ||
| this.#teardownPromise = undefined; | ||
| } | ||
| }); | ||
| this.#teardownPromise = teardown; | ||
| } | ||
| return this.#teardownPromise; | ||
| } | ||
|
|
||
| async #doClose() { | ||
| this.#destroy = true; | ||
|
|
||
| if (this.#connectPromise != undefined) { | ||
| await this.#connectPromise.catch(() => undefined); | ||
| } | ||
|
|
||
| this.#isReady = false; | ||
| this.#setReady(false); | ||
|
|
||
| this.#clientSideCache?.onPoolClose(); | ||
|
|
||
|
|
@@ -1219,19 +1330,39 @@ export class RedisSentinelInternal< | |
|
|
||
| this.#pubSubProxy.destroy(); | ||
|
|
||
| this.#isOpen = false; | ||
| // 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(). Also start a new | ||
| // teardown generation: a close()/destroy() from inside the emit must tear down a | ||
| // reentrantly reopened sentinel, not join this already-finished pass. | ||
| this.#destroy = false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 67b0305: teardown now releases the reserved lease, so reopening with |
||
| this.#teardownPromise = undefined; | ||
| this.#setOpen(false); | ||
|
Comment on lines
+1338
to
+1340
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — real but needs overlapping
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Revisited after a second independent confirmation — fixed in db5749a: |
||
| } | ||
|
|
||
| // Coalesces with an in-flight close()/destroy() — see close() above. | ||
| async destroy() { | ||
| if (this.#teardownPromise === undefined) { | ||
|
Comment on lines
+1344
to
+1345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| const teardown: Promise<void> = this.#doDestroy().finally(() => { | ||
| if (this.#teardownPromise === teardown) { | ||
| this.#teardownPromise = undefined; | ||
| } | ||
| }); | ||
| this.#teardownPromise = teardown; | ||
| } | ||
| return this.#teardownPromise; | ||
| } | ||
|
|
||
| // destroy has to be async because its stopping others async events, timers and the like | ||
| // and shouldn't return until its finished. | ||
| async destroy() { | ||
| async #doDestroy() { | ||
| this.#destroy = true; | ||
|
|
||
| if (this.#connectPromise != undefined) { | ||
| await this.#connectPromise.catch(() => undefined); | ||
| } | ||
|
|
||
| this.#isReady = false; | ||
| this.#setReady(false); | ||
|
|
||
| this.#clientSideCache?.onPoolClose(); | ||
|
|
||
|
|
@@ -1263,8 +1394,12 @@ export class RedisSentinelInternal< | |
|
|
||
| this.#pubSubProxy.destroy(); | ||
|
|
||
| this.#isOpen = false | ||
| // Clear #destroy before emitting `end` (via #setOpen), so a reentrant connect() | ||
| // from an `end` listener sees teardown finalized and can reopen cleanly. Also | ||
| // start a new teardown generation — see #doClose(). | ||
| this.#destroy = false; | ||
| this.#teardownPromise = undefined; | ||
| this.#setOpen(false); | ||
| } | ||
|
|
||
| async subscribe<T extends boolean = false>( | ||
|
|
@@ -1482,6 +1617,12 @@ export class RedisSentinelInternal< | |
|
|
||
| if (analyzed.masterToOpen) { | ||
| this.#trace(`transform: opening a new master`); | ||
| // The master actually changed (analyze() leaves masterToOpen undefined otherwise). | ||
| // If the sentinel was already running and ready, it is now reconfiguring: drop | ||
| // readiness (emits `reconnecting`). #connect() re-asserts readiness (emits `ready`) | ||
| // once the new topology is connected. On the initial connect `#isReady` is still | ||
| // false, so this is a no-op and no `reconnecting` is emitted. | ||
| this.#setReady(false); | ||
|
nkaradzhov marked this conversation as resolved.
|
||
| const masterPromises = []; | ||
| const masterWatches: Array<boolean> = []; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in the current code is that the replacement
erroremission is still inside#connect()'s topology-retry boundary: when areadyorreconnectinglistener throws and the public sentinel has noerrorlistener, 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#isReadywas already changed. The initiating operation can therefore succeed after a one-second delay while silently swallowing the exception, contrary to the documented unhandled-errorbehavior; dispatch or propagate this error outside the topology retry path.Useful? React with 👍 / 👎.