Repository navigation
Sentinel: discover and recover without blocking thread-pool workers - #3301
rebaseandpanic wants to merge 4 commits into
Conversation
A sentinel-managed connection that loses its primary arms a reconnect timer that fires every second until OnManagedConnectionRestored stops it. Each tick ran SwitchPrimary, which asked every sentinel through a PLINQ query of synchronous commands: one blocked pool worker per sentinel endpoint, waiting on socket completions that are themselves pool work items. With a handful of sentinel endpoints this takes the whole pool, and the process stalls until the pool injects threads - every second, for as long as the timer runs. The timer can also run forever: a node that never returns at its old address, or a Restored delivered before its Failed, leaves nothing to stop it. - Sentinel queries use the async API, asking every sentinel at once with one deadline for the whole query; the first answer wins and the rest are only observed. No worker waits on a sentinel. - SwitchPrimary becomes SwitchPrimaryAsync, with a per-connection gate around discovery and reconfiguration. A switch requested while one is running is recorded and replayed instead of overlapping it. - The reconnect timer is one-shot: the next attempt is armed one second after the previous one finished. It stops on state - the sentinel-reported primary is connected, is not a replica, and no other node is a connected primary - rather than on the order Failed/Restored arrive in. - The +switch-master, +sentinel, lost-sentinel, restore and demotion paths start async work instead of blocking their caller; demotion recovery sends INFO through TryWriteAsync, and a write that did not go out is not taken as a role check. - GetSentinelMasterConnection and the async connect share one async core. The public synchronous Connect keeps its soft ConnectTimeout behaviour with AbortOnConnectFail=false, and is the only remaining synchronous wait. - Two concurrent GetSentinelMasterConnection calls for one service return the same child; the losing connection is disposed. - Restored no longer runs a switch unconditionally: a connection already on the sentinel-reported primary, with no stale view and no recovery running, is left alone; anything else schedules the same recovery as Failed. - The sentinel address list is refreshed after the first switch of a managed connection as well, not only after a switch that reconfigured it. Which sentinel answer the client accepts, and how it reconciles replicas, is unchanged here. Spec: https://redis.io/docs/latest/develop/reference/sentinel-clients/#step-1-connect-to-the-first-sentinel https://redis.io/docs/latest/develop/reference/sentinel-clients/#step-2-ask-for-the-master-address https://redis.io/docs/latest/develop/reference/sentinel-clients/#handle-reconnections https://valkey.io/topics/sentinel-clients/#step-1-connect-to-the-first-sentinel https://valkey.io/topics/sentinel-clients/#step-2-ask-for-primary-address https://valkey.io/topics/sentinel-clients/#handling-reconnections
…ca view consistent With every sentinel asked at once, the first answer wins - so which answer is taken matters more than when the sentinels were asked one by one. - The primary is read with SENTINEL MASTER and accepted only from a sentinel that holds it as a healthy master (flags contain master, no s_down/o_down). A sentinel still repeating a failed primary is passed over for one that already knows the new one. get-master-addr-by-name, which carries no flags, is the fallback only when SENTINEL MASTER itself is not supported; any other error is not a reason to trust a flagless answer. - Replicas are read from the sentinel whose primary answer was accepted, not from a second race that could combine two unrelated views. - Every switch refreshes the replica set, also for a primary the connection already knows; before, only an unknown primary did, so a replica that came back at a new address left the old one in the snapshot for good. - Sentinel keeps reporting failed replicas (it never forgets them until SENTINEL RESET). Down flags only decide whether a node not yet held is added; a node this connection holds and is connected to is kept whatever one sentinel's momentary view says. Only a disconnected node missing from a nonempty reply is retired, and an empty or failed replica query retires nothing. Spec: https://redis.io/docs/latest/develop/reference/sentinel-clients/#step-2-ask-for-the-master-address https://redis.io/docs/latest/develop/reference/sentinel-clients/#connect-to-replicas https://redis.io/docs/latest/develop/reference/sentinel-clients/#sentinel-failover-disconnection https://valkey.io/topics/sentinel-clients/#step-2-ask-for-primary-address https://valkey.io/topics/sentinel-clients/#connecting-to-replicas https://valkey.io/topics/sentinel-clients/#sentinel-failover-disconnection
…ed-down routes last - The +sentinel handler passed messageParts[0] - the literal instance type "sentinel" - as the service name, so the address refresh it triggers never matched a service and the announcement was never acted on. The service is read after the "@" separator. - The sentinel list stays append-only, as before: seeds and routes found earlier are kept, and a Service address and the pod address the same sentinel advertises are both kept without being matched to each other. What changes is how a refresh runs: new routes are added before the reconfigure is claimed, so a busy reconfigure cannot drop what was discovered, and a refresh requested while one is running is replayed rather than lost. - Every route is still asked at once, but connected routes are started first and routes sentinel itself reports down last. Spec: https://redis.io/docs/latest/develop/reference/sentinel-clients/#step-1-connect-to-the-first-sentinel https://redis.io/docs/latest/develop/reference/sentinel-clients/#sentinels-list-automatic-refresh https://redis.io/docs/latest/develop/reference/sentinel-clients/#subscribe-to-sentinel-events-to-improve-responsiveness https://valkey.io/topics/sentinel-clients/#step-1-connect-to-the-first-sentinel https://valkey.io/topics/sentinel-clients/#sentinels-list-automatic-refresh https://valkey.io/topics/sentinel-clients/#subscribe-to-sentinel-events-to-improve-responsiveness
A primary demoted in place answers writes with READONLY long before the next INFO replication poll (ConfigCheckSeconds, 60s by default) notices the role change, and until then commands keep failing against it. On a sentinel-managed connection a READONLY reply now marks the node as a replica right away - locally, without starting a reconfigure on the IO path - and the demotion heartbeat then asks sentinel and switches, as it does for a demotion seen by INFO. Standalone and cluster connections keep their existing READONLY behaviour. Spec: https://redis.io/docs/latest/develop/reference/sentinel-clients/#step-3-call-the-role-command-in-the-target-instance https://redis.io/docs/latest/develop/reference/sentinel-clients/#sentinel-failover-disconnection https://valkey.io/topics/sentinel-clients/#step-3-call-the-role-command-in-the-target-instance https://valkey.io/topics/sentinel-clients/#sentinel-failover-disconnection
mgravell
left a comment
There was a problem hiding this comment.
Thanks for this, and for the trace; the diagnosis is solid.
Reproduction
I reproduced it against main with the in-process sentinel servers. The setup: N sentinel-managed multiplexers, 6 sentinel endpoints, and the pool minimum set to 6 (the production default, not the test suite's 64). A replica then disappears for good. A probe is queued to the pool every 50ms, alongside async GET load, for 15s:
| scenario | main |
this PR |
|---|---|---|
| N=1 | no stall; timer never stops (~90 sentinel queries / 15s) | timer stops |
| N=11 | 3 probes ran in 15s, max 6.5s delay, 0 GET rounds completed; 11/11 timers still running | max 0.03ms, timers stopped |
| N=11, sentinel replies +300ms | max 9.7s, 0 GET rounds | max 0.03ms |
The in-process server didn't surface the replica disconnect to the client, so I delivered ConnectionFailed by hand, as the PR's own tests do. The new sentinel unit tests passed three runs in a row for me.
Unintended consequences
Two behaviour changes that I think need addressing before this can merge:
-
Sentinel ACL users without
SENTINEL MASTERcan no longer connect. Every primary lookup now goes throughSENTINEL MASTER, andIsSentinelMasterUnsupportedonly falls back toget-master-addr-by-nameon "unknown command/subcommand" text.NOPERMis treated as "no answer", so a sentinel user restricted toget-master-addr-by-name/replicas/sentinels(everythingmainneeds) now fails withSentinel: Failed connecting to configured primary for service. I confirmed this withPrimaryError = "NOPERM User client has no permissions to run the 'sentinel|master' command"on a single sentinel. I'd expectNOPERMto take the legacy path too. -
A replica that sentinel reports down at connect time is never added. At connect, and in
SwitchPrimaryAsync, replicas withs_down/o_down/disconnectedare skipped, and recovery then stops once the primary is healthy. After that, nothing asks sentinel again until some other event (a connection failure,+switch-master, a demotion). I setReplicaFlags[ReplicaB] = "s_down", connected, then cleared the flag: the replica was still absent 15s later.mainadded every listed replica and let the bridge keep retrying, so with replica reads, starting up during a rolling restart can now leave a replica unused for the life of the process.ReplicaReportedDownJoinsOnRecoveryOnceItsFlagsCleardoesn't catch this because it callsSwitchPrimaryAsyncdirectly. Either keep adding down replicas, as before, or have the heartbeat re-read replicas while sentinel reports one down.
A few smaller points I've checked and am OK with, so mentioning them for the record only:
- The fastest valid sentinel answer now wins rather than the first in list order. This is mitigated by the flags check and the ROLE check.
ConnectionRestoredno longer switches unconditionally. A missed failover is still covered, becauseConnectionFailedarms the timer, including whenRestoredarrives first.READONLYmarks a sentinel-managed node as a replica immediately. A false positive heals through the demotion heartbeat'sINFO replication, so I'm fine with it.- A replica that comes back at a new address is still not picked up without another event. That's the same as
main, so not a regression, but the results table could read as though it's fixed.
What we saw in production
A .NET 9 service on 6 CPUs froze for 0.5–5 s at a time: liveness probes (an endpoint with no I/O) took up to 4.9 s, and readiness failed ~15 times a day on one pod. It connects in Sentinel mode (
ServiceName) through libraries that create their own multiplexers (DotNetCore.CAP's Redis Streams transport and the SignalR Redis backplane): up to 11 in one process, each with its own reconnect timer.An EventPipe trace (counters, GC/contention/thread pool events, then a continuous sample profile) ruled out GC (max pause 224 ms) and locks (2.1 s of contention in 15 min, none ≥ 200 ms). During every freeze the process CPU was ~0, the pool ran 0–5 work items/s instead of thousands, and a
ThreadPool Starvationevent followed within ~1 s. In each freeze window all 6–8 busy pool threads were in:The timer was active in 283 of the 300 traced seconds.
The problem: in Sentinel mode the reconnect timer can run forever, and each tick blocks one pool thread per sentinel endpoint on synchronous calls whose replies need a pool thread themselves. With several multiplexers in one process this starves the thread pool and freezes the whole application for seconds. This PR removes the blocking from that path and stops the timer once the connection is back on the primary that sentinel reports.
Mechanism
ConnectionFailedon a managed connection starts the reconnect timer: every second, untilConnectionRestored. If no connection of that multiplexer is established again (a replica that came back at a new address is never added, because the switch only rebuilds for an unknown primary), or ifRestoredis handled beforeFailed(both go through the pool, in no guaranteed order), it never stops.SENTINEL sentinelsare not merged, so 3 sentinels are 6 endpoints, and on a 6-CPU box that is the whole pool.ThreadPool.SetMinThreads(64)only masks it: with 11 multiplexers, 11 × 6 blocked threads exceed 64 and the freezes return (up to 2.55 s on the test bench).This is the same starvation as #2790, which was closed as likely fixed by the 3.0 IO rewrite. It still reproduces on 3.4.20. The
serviceName = "sentinel"in that issue's stack is a second bug, fixed here too (commit 3).Fix
Four commits, each builds and tests on its own:
SwitchPrimaryAsynchas a per-connection gate, and a switch requested during another one is replayed. The reconnect timer is one-shot (next attempt one second after the previous finished) and stops on state: connected to the sentinel-reported primary, which is not a replica, with no other connected primary. The public syncConnectis the only synchronous wait left.Spec: Redis Step 1, Step 2, reconnections; Valkey Step 1, Step 2, reconnections.
SENTINEL MASTERwithmasterand nos_down/o_down(get-master-addr-by-nameonly whenSENTINEL MASTERis unsupported); an answer flagged down is treated like the null reply in Step 2, and the next sentinel's answer is used. Replicas are read from the same sentinel, every switch refreshes the replica set, and live nodes are not retired on a partial view.Spec: Redis Step 2, replicas, failover disconnection; Valkey Step 2, replicas, failover disconnection.
+sentinelpassed the literalsentinelas the service name, so it never worked; the service is now read after@. The list stays append-only, as the spec describes, discoveries are not lost when a reconfigure is busy, and routes sentinel reports down are asked last.Spec: Redis Step 1, list refresh, events; Valkey Step 1, list refresh, events.
READONLYon a sentinel-managed connection marks the node as a replica immediately (no reconfigure on the IO path); the demotion heartbeat from Sentinel: re-resolve the primary when it is demoted on a healthy connection #3281 then asks sentinel. This covers the demoted primary from "failover disconnection" when the connection was not killed.Spec: Redis Step 3, failover disconnection; Valkey Step 3, failover disconnection.
Behaviour that changes beyond the starvation:
ConnectionRestoredno longer runs a switch when the connection is already on a healthy sentinel-reported primary;Results
Test bench with the production shape (6 sentinel endpoints: 3 DNS names plus 3 announced IPs, default pool minimum, 6 CPUs), 180 s per run. The metric is the delay of a probe queued to the pool every 50 ms, with async SET/GET load. N = number of sentinel-managed multiplexers.
SENTINEL FAILOVER, old primary back at a new IPSENTINEL FAILOVER, old primary back at a new IPNo episode over 500 ms in any fixed run. Production results will follow as a comment once there is enough data.
Tests
SentinelPrimaryDemotionUnitTests,SentinelDiscoveryUnitTests): event order, timer spacing, replayed switches, dispose during recovery,SENTINEL MASTERflags and the legacy fallback, replicas from the same sentinel, partial views, the sentinel list,+sentinelparsing,READONLY, malformed addresses.Sentinel*tests against the docker topology withRunLongRunning, plusManagedPrimaryConnectionEndToEndWithFailoverTestthree times.SentinelDiscoveryKeepsConfiguredNamesAndPreviouslyKnownRoutesnow checks that the list does not shrink; it asserted that it does not change, which races on which sentinel answers first (a sentinel never lists itself).Refs #2790
Checklist