Skip to content

Sentinel: discover and recover without blocking thread-pool workers - #3301

Open
rebaseandpanic wants to merge 4 commits into
StackExchange:mainfrom
rebaseandpanic:pr/sentinel-nonblocking
Open

rebaseandpanic wants to merge 4 commits into
StackExchange:mainfrom
rebaseandpanic:pr/sentinel-nonblocking

Conversation

@rebaseandpanic

Copy link
Copy Markdown
Contributor

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 Starvation event followed within ~1 s. In each freeze window all 6–8 busy pool threads were in:

TimerQueueTimer.Fire
 → OnManagedConnectionFailed (reconnect timer)
 → SwitchPrimary → GetConfiguredPrimaryForService
 → PLINQ First() → RedisServer.ExecuteSync   // SENTINEL get-master-addr-by-name

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

  1. Any ConnectionFailed on a managed connection starts the reconnect timer: every second, until ConnectionRestored. 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 if Restored is handled before Failed (both go through the pool, in no guaranteed order), it never stops.
  2. Each tick asks every sentinel through PLINQ with synchronous commands, one pool thread per sentinel endpoint. Configured DNS names and the IPs from SENTINEL sentinels are not merged, so 3 sentinels are 6 endpoints, and on a 6-CPU box that is the whole pool.
  3. Socket completions are pool work items too, so the replies cannot be read until the pool injects a thread (~1 s per injection). The sentinels are not slow; the process blocks itself. A freeze lasts until enough threads have been injected.

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:

  1. No blocking in the background path. Sentinel is queried with the async API, all sentinels at once, one deadline, first valid answer wins (Step 1 tries sentinels one by one with a short timeout each; one deadline for all keeps that bound for the whole round). SwitchPrimaryAsync has 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 sync Connect is the only synchronous wait left.
    Spec: Redis Step 1, Step 2, reconnections; Valkey Step 1, Step 2, reconnections.
  2. Take only a healthy primary answer. With every sentinel racing, the answer must be checked: SENTINEL MASTER with master and no s_down/o_down (get-master-addr-by-name only when SENTINEL MASTER is 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.
  3. Sentinel list. +sentinel passed the literal sentinel as 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.
  4. READONLY on 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:

  • ConnectionRestored no longer runs a switch when the connection is already on a healthy sentinel-reported primary;
  • the fastest valid sentinel answer wins, instead of the first sentinel in the list;
  • the sentinel list is also refreshed after the first switch of a managed connection.

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.

Scenario N 3.4.20 max 3.4.20 timer stops fixed max fixed timer stops
replica replaced by a new pod 1 993 ms no 0.14 ms yes
replica replaced by a new pod 11 13.97 s no 0.14 ms yes
SENTINEL FAILOVER, old primary back at a new IP 1 5.0 s no 0.16 ms yes
SENTINEL FAILOVER, old primary back at a new IP 11 34.99 s no 0.11 ms yes
two sentinels +2000 ms latency, replica replaced 11 18.98 s no 0.15 ms yes
two sentinels blackholed, replica replaced 11 22.77 s no 0.23 ms yes
replica gone, old IP never returns 11 — — 0.19 ms yes

No episode over 500 ms in any fixed run. Production results will follow as a comment once there is enough data.

Tests

  • In-process sentinel tests (SentinelPrimaryDemotionUnitTests, SentinelDiscoveryUnitTests): event order, timer spacing, replayed switches, dispose during recovery, SENTINEL MASTER flags and the legacy fallback, replicas from the same sentinel, partial views, the sentinel list, +sentinel parsing, READONLY, malformed addresses.
  • All Sentinel* tests against the docker topology with RunLongRunning, plus ManagedPrimaryConnectionEndToEndWithFailoverTest three times. SentinelDiscoveryKeepsConfiguredNamesAndPreviouslyKnownRoutes now 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

  • I fully and freely contribute this code in accordance with the project license (and am legally able to do so)
  • I take responsibility for this contribution's quality and correctness, including any portions produced with AI assistance (see CONTRIBUTING.md).

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 mgravell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. Sentinel ACL users without SENTINEL MASTER can no longer connect. Every primary lookup now goes through SENTINEL MASTER, and IsSentinelMasterUnsupported only falls back to get-master-addr-by-name on "unknown command/subcommand" text. NOPERM is treated as "no answer", so a sentinel user restricted to get-master-addr-by-name / replicas / sentinels (everything main needs) now fails with Sentinel: Failed connecting to configured primary for service. I confirmed this with PrimaryError = "NOPERM User client has no permissions to run the 'sentinel|master' command" on a single sentinel. I'd expect NOPERM to take the legacy path too.

  2. A replica that sentinel reports down at connect time is never added. At connect, and in SwitchPrimaryAsync, replicas with s_down/o_down/disconnected are 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 set ReplicaFlags[ReplicaB] = "s_down", connected, then cleared the flag: the replica was still absent 15s later. main added 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. ReplicaReportedDownJoinsOnRecoveryOnceItsFlagsClear doesn't catch this because it calls SwitchPrimaryAsync directly. 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.
  • ConnectionRestored no longer switches unconditionally. A missed failover is still covered, because ConnectionFailed arms the timer, including when Restored arrives first.
  • READONLY marks a sentinel-managed node as a replica immediately. A false positive heals through the demotion heartbeat's INFO 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.

This branch has not been deployed

No deployments
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.

2 participants