Repository navigation
Lag-aware failback checks for geo-redundant failover - #3242
Merged
Merged
Conversation
mgravell
force-pushed
the
marc/lag-aware-availability
branch
from
September 18, 2026 04:34
cce049c to
916a563
Compare
mgravell
force-pushed
the
marc/lag-aware-availability
branch
from
October 6, 2026 16:03
701beba to
10b2c0b
Compare
mgravell
force-pushed
the
marc/lag-aware-availability
branch
from
October 13, 2026 10:24
74c4b82 to
010f62c
Compare
Confirms the premise: this is the client-side geographic failover feature family, and Lettuce, Jedis and redis-py have all shipped it. It feeds exactly the health checks our Availability/ namespace already has behind SER007. The abstractions line up almost one-to-one with redis-py's - probe, policy (all/any/majority), circuit breaker, per-member config - and two details make it land cleanly: HealthCheckProbe has no internal abstract members, so it is already subclassable from outside the assembly, and ConnectionGroupMember already carries a per-member HealthCheck override, which is what a per-cluster REST endpoint needs. Where we are better placed: HealthCheckResult has Inconclusive, which Lettuce's two-state HealthStatus lacks. Lettuce maps a failed REST call to UNHEALTHY, so a management-plane outage is indistinguishable from the data plane being down and can fail over a database that is serving fine. We can say "could not determine" instead. The awkward part is that src/ has no HTTP client and no JSON parser. JSON turns out to be avoidable if the bdb uid is configured rather than discovered, since the availability call is a status-code check. Also records the defaults disagreeing across clients: 100ms everywhere - server, docs, redis-py - but 5000ms in Lettuce.
mgravell offered to add one; the shape is what decides whether it helps. A member returning HttpMessageHandler would drag System.Net.Http into StackExchange.Redis's public API for the sake of one commercial deployment target - which is the thing keeping this in a separate package was meant to avoid, decided the expensive way. A stream-shaped member instead composes into SocketsHttpHandler.ConnectCallback, which takes exactly a ValueTask<Stream>, so the Enterprise side gets a complete HTTP hijack and core gains no HTTP types at all. Tunnel already speaks that language via BeforeAuthenticateAsync and ConnectTransportAsync, and adding a virtual is binary-safe for subclasses. Two functional arguments for Tunnel over a separate knob, beyond testing: a CONNECT proxy configured for Redis almost certainly applies to the Enterprise REST API too, so co-locating makes that correct by construction; and the test harness already assigns Tunnel, so one assignment would spoof both planes. Caveats recorded: ConnectCallback is net5+, the existing members are EndPoint/ConnectionType-shaped where HTTP wants a Uri, and widening Tunnel's remit means HttpProxyTunnel and LoggingTunnel each need an opinion about whether they apply to the management plane. Also retires open question 5: RawConfig and ConfigurationOptions.Tunnel are both already public, so a probe reaches the tunnel from the context it already gets - no HealthCheckContext change needed.
I had written that inducing "reachable but stale" on demand is not something anyone can do in CI, so the fakes were the only route to the scenario that matters. That is wrong. Redis ships a fault injection service that the client suites drive over HTTP - POST /action with a type and parameters, poll the returned action_id - and Lettuce's ActiveActiveFailoverScenarioTest injects network_latency against a bdb_id for a bounded duration, which is exactly the mechanism for pushing replication lag past the tolerance and watching the lag-aware check flip while the plain check stays green. So the authoritative end-to-end test is real cluster plus fault injector, on a road Lettuce has already built. The fakes are demoted rather than removed: they still cover machines with no Enterprise cluster, they are far faster than an injector action that budgets minutes plus a stabilisation wait, and they cover what the injector does not obviously reach - specific error_code bodies, 401/403 credential expiry, slow-but-successful responses for probe timeouts. Also records what the real clusters are uniquely good for: everything in section 1 is read off documentation, and a short session settles whether extend_check=lag is actually supported, what failure bodies really contain, whether the Host header is required, what the bdbs listing really returns - which decides whether the JSON-avoidance argument holds - and what lag real geo links show, which is the empirical way to settle 100ms versus 5000ms.
… questions It is not merged - tests/StackExchange.Redis.FaultInjector.Tests lives on marc/maint-optin-server, open as #3191 (107 files, +13160) - which is presumably why I did not find it when I grepped a branch taken fresh from main. Two pieces of it bear directly on this. FaultInjectorClient wraps the same POST /action + GET /action/{id} contract go-redis, redis-py and node-redis converged on, and adds GetValidTriggersAsync, so we can ask a deployment which lag-inducing effects it supports instead of guessing at an action name. And ClusterRestClient is already a Redis Enterprise REST client on 9443 - Basic auth, System.Text.Json, GET /v1/bdbs?fields=uid,name - so the availability call is a method on an existing class, not a new project. It also settles by observation what I had listed as open and sourced only from documentation: the management certificate is self-signed per environment and pinned to a CA from the config directory, so the probe needs CA configuration and cannot rely on the ambient trust store; auth really is Basic; and the bdbs listing returns the requested fields, so discovery is easy - which weakens the avoid-JSON argument to "it keeps JSON out of src/", not "discovery is hard". Sequencing consequence recorded: an end-to-end test is downstream of #3191. And #3191's own summary is the best argument for validating before building - same vendor, same class of API: the specifications are prose, the payloads were never published, and nearly every starting assumption was wrong.
Step 0 is to wait for #3191 to merge and build from there, rather than writing a second Redis Enterprise REST client against the same cluster and reconciling the two later - its ClusterRestClient already does Basic auth, CA pinning and GET /v1/bdbs, and its FaultInjectorClient can be asked which lag-inducing effects a deployment supports. The one thing that can start earlier is the API validation, because it produces notes rather than code. Step 1 is that validation, before any implementation: a checklist of things currently taken from documentation, including the measurement that settles 100ms versus 5000ms, with captured bodies saved as fixtures so the stubbed tests replay real payloads. Step 2 deliberately does not decide where the code lives, but records that the dependency argument is weaker than it looked - System.Net.Http needs no package reference on any target we ship - so the question is judgement, not constraint. Also records that this branch becomes the feature branch when step 0 clears, and that the way to take the merge is to rebase onto main rather than merge main in, since #3191 squash-merges.
…cess #3191 merged as dc915bb, so this branch is rebased onto it - which is what step 0 asked for, and rebased rather than merged because #3191 squash-merged and a merge would have moved the merge base without bringing its ancestry. The fault-injector tier is present here now. Also recorded where the work can happen. Step 1 is validation against a real deployment plus the fault injector, and both live on the machine holding the environment directory; this box has no ~/aws and no env_output.json anywhere, so nothing in step 1 could be run or verified here. Worth writing down rather than rediscovering, since the environment being absent looks identical to the environment being offline. Steps 2 onward are ordinary code and are not tied to a machine. Noted too that the directory is the AWS multi-cluster template, so env_output.json nests its outputs under .clusters.value[0] - a shape FaultInjectorEnvironment already handles, so it needs no work, just no surprise.
… config CA The 9443 certificate is self-signed by the cluster itself; the CA in the config directory only signs the RESP proxy certificates, so the chain build could never pass. Pin the leaf on first use, keyed by host so multi-cluster templates work.
…t says there Records the real-deployment results (findings section 10): off Active-Active the lag check fails unconditionally; on it, the stale member flips within seconds of sync pausing and the tolerance is honoured on a running link. Also the two integration problems found in Availability/, which mean src/ needs a change.
…to 5000 ms Read from source: redis-py's 5000 contradicts its own docstring's 100. None of the three checks for Active-Active, and none has a third result state.
…ewer suffices Active-Active membership has no RESP signal, so it comes from REST (fields=crdt) or configuration. db_viewer can call everything a probe needs, but unfiltered bdb/crdb reads expose database passwords even to it, so only filtered requests. The REST endpoint derives from the configured database hostname.
…base host TrustIssuer(leaf) via TlsOptions validates the 9443 call unchanged. No other client activates lag checks automatically or checks crdt; go-redis routes the local form through each database host, which makes it truthful, and scopes the lag verdict to failback only.
Same verdict, same timing, stale member only, tolerance ignored while paused. Its failure code is bdb_endpoint_unavailable, shared with the wrong-node case, so the choice between forms is about call shape, not information.
A failback-only health check per member rather than a new result value, a member-scoped probe, and a LagAware factory whose only required input is the management credential. Compared against Lettuce, Jedis, redis-py and go-redis.
…her clients Keep LagAware naming and failback (not fallback) terminology; rotating NetworkCredential callback plus a separate client-certificate option; and keep the liveness fallback, which no client has, because a dead peer probably makes the survivor's lag check fail.
…th sides Partitioning the clusters (tagged iptables rules, self-healing) showed no lag for ~60 s while sync had stopped, then tolerance-governed failure on both members; a tolerance of 0 is honoured. Supports the liveness fallback, and adds a caveat: failback in the first minute after a link breaks is not protected.
The default policy reported Healthy when no probe failed, even if none succeeded. Harmless for liveness, where both count as connected, but wrong for any caller that distinguishes them.
…ing to it (SER011) ConnectionGroupMember.FailbackHealthCheck (and a group default) is consulted only for members that are not active, and only Healthy makes them eligible. It never evicts the active member, and when no live member is eligible, selection falls back to liveness, since a partition leaves every region behind at once. HealthCheckProbe.Scope lets a probe run once per member rather than per endpoint.
…ble and caught up (SER011) A member-scoped probe of the database availability API. Only the management credentials are required: the REST endpoint derives from the configured host, the database is discovered with filtered requests only, the lag question is asked only of Active-Active databases, and no tolerance is sent unless set. Could-not-ask is Inconclusive, never Unhealthy. System.Text.Json on the down-level targets; custom certificate validation is unavailable on net461.
Found against live clusters: non-master nodes answer the management routes
(discovery, the crdt lookup) with a 307 to the master's internal address, so
deriving the REST endpoint from the database host timed out for three of four
members. Derive the cluster FQDN (strip redis-{port}.), which resolves to the
master, and report a redirect as Inconclusive rather than following it.
…ve member Pauses sync into the preferred member through its cluster's REST API while writes go to the other, and asserts the group stays off it for 15s although it is alive, then fails back once it has caught up. The tier now reads every cluster's credentials, and shares its certificate pin with the probe.
LagAwareOptions.ClientCertificate, with SetUserPfxCertificate and (net6.0+) SetUserPemCertificate mirroring ConfigurationOptions and sharing its loaders. Wired into SocketsHttpHandler on .NET and HttpClientHandler down-level; not supported on net461. Every other client offers mTLS for this call.
Other clients never select a member that fails its lag check; the nearest equivalent that still serves when every member is behind is to leave such a member for an eligible one as soon as one is available, whatever the weights, and to keep checking it so it becomes an ordinary active member once caught up.
AnySuccess reported Unhealthy whenever nothing succeeded, even when nothing failed either - so a probe that cannot tell (a key-write probe on a replica, say) marked its member unhealthy. Now Inconclusive, matching AllSuccess and MajoritySuccess.
main has since taken 128 as well.
mgravell
force-pushed
the
marc/lag-aware-availability
branch
from
October 13, 2026 10:29
010f62c to
059f7dc
Compare
…k; no ValueTuple UpdateState compared the last failure strictly before the cutoff. With FailbackDelay zero the cutoff is now, and on .NET Framework's ~15.6ms clock a failure and the passing check that clears it often share a tick, holding the member out for another interval. Compare inclusively. Also replace a named tuple in the failback-check pass: the library must not reference System.ValueTuple.
mgravell
marked this pull request as ready for review
October 13, 2026 13:08
Remove SER011: the attributes, the constant and NoWarn entry, the [SER011] API prefixes and docs/exp/SER011.md. It never shipped, so it is not a reserved id. Also restores main's using directives and encoding in the files that already existed there, which earlier edits had disturbed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Implementation is in. Not gated behind an experiment: like the rest of
Availability/, it ships as regular API. Everything new is additive; the existing API is not changed apartfrom three bug fixes (below).
What this adds
A group can now ask, before moving traffic to a member, whether that member is fit to take it, not only
whether it is alive. For Redis Enterprise Active-Active deployments, a built-in probe asks the cluster's own
database availability API whether the
member is available and caught up, so a group does not fail back onto a region that is reachable but stale.
Core (group selection)
ConnectionGroupMember.FailbackHealthCheckandMultiGroupOptions.FailbackHealthCheck: consulted only formembers that are not active; only
Healthymakes them eligible (Inconclusivedoes not).HealthCheck.group still serves from the best live member. That member is provisional: it is left for an eligible
member as soon as one is available, and becomes an ordinary active member once it passes its own check.
Other clients never select a member that fails its lag check; this is the nearest equivalent that still
serves.
HealthCheckProbe.Scope/HealthCheckProbeScope: a probe can run once per member instead of once perendpoint.
The probe (
HealthCheck.LagAware,HealthCheckProbe.LagAware,LagAwareOptions,LagCheckMode)Only the management credentials are required; everything else is derived or left to the cluster:
https://{cluster}:9443/, from a database hostredis-{port}.{cluster}/v1/bdbsreturns database passwords, even todb_viewerInconclusive, neverUnhealthyTrustIssuer(...)/CertificateValidationClientCertificate,SetUserPfxCertificate,SetUserPemCertificateCredentials are re-read per request (rotation). Custom certificate validation and client certificates are
unavailable on
net461.System.Text.Jsonis a new package reference onnet461,net472andnetstandard2.0only (NRedisStack already depends on it).Bug fixes in GA code
Each a separate commit with tests.
FailbackDelay = 0on .NET Framework.ConnectionGroupMember.UpdateStatecleared a failure only if itwas strictly before the cutoff (
now - FailbackDelay). On .NET FrameworkDateTime.UtcNowadvances aboutevery 15.6 ms, so a failure and the passing check that should clear it often share a tick, and the member was
held out for another health-check interval despite "immediate failback". Now inclusive; no measurable change
for any positive delay. Found because the new group tests failed on Windows
net481CI only.HealthCheckProbePolicy: a run of nothing butInconclusiveprobes ("could not tell") now reportsInconclusiveunder every policy:AllSuccess(the default) reported it asHealthy, contrary to its own comment. Harmless for liveness(both count as connected), but it would have made "could not ask the REST API" read as "caught up".
AnySuccessreported it asUnhealthy, contrary to its own comment ("fail only when all probes havefailed"). That one did affect liveness: a probe that cannot tell (
StringSeton a replica endpoint, forexample) marked its whole member unhealthy.
Impact on people who do not opt in (for the release notes)
The feature itself is inert unless configured.
FailbackHealthCheckis null by default (member and group),in which case every member counts as eligible, the provisional state is never entered, and selection is
exactly as before; the per-pass check loop finds nothing and returns before allocating. The existing group
tests pass unchanged. Nothing enables it automatically (no options provider changed).
What does change for everyone:
System.Text.Jsonpackage dependencynet461,net472,netstandard2.0consumers (in-box onnet8.0+);net461/net472also gain a framework reference toSystem.Net.HttpAnySuccess: all-InconclusiveisInconclusive, notUnhealthyAnySuccessAllSuccess(default): all-InconclusiveisInconclusive, notHealthyHealthCheck.CheckHealthAsyncdirectly and testing forHealthyUpdateStateand endpoint collation treat both alike)FailbackDelay = 0fails back within the same clock tickNone of these is a reason not to merge, but the first, second and fourth belong in the release notes. The
three fixes could move to a separate PR if this one should change nothing for non-users; the dependency
cannot, since the probe lives in the main package.
How it was validated
notes/lag-aware/findings.mdsection 10 has the measurements; highlights, all on RS 8.0.22:its plain check stays 200; the tolerance is honoured on a running link (100 ms fails, 600000 ms passes
under a write burst).
fails on both sides. That is why the liveness fallback exists, and why the docs recommend a
FailbackDelayof at least that long.
paused;
Inconclusive(with a precise log line) for wrong credentials and for an untrusted certificate.This run also found the master-redirect behaviour, which the stubbed tests could not.
Tests:
FailbackHealthCheckTests(selection rules) andLagAwareProbeTests(the probe against a stubbed transportreplaying captured response shapes); the availability suite passes on
net10.0andnet8.0, and the newtests on
net481under Mono.LagAwareFailbackScenarioTestsin the fault-injector tier: on a live Active-Active pair, the group held offthe preferred member for 15 s while it was behind (alive and connected), and failed back about 4 s after
sync resumed, by which time it had all of the writes. No network fault is used: sync is paused through the
cluster's REST API and resumed in a
finally. Passed on three separate environments, the last time on thisbranch as rebased.
Docs
docs/Failover.mdgains "Failback health checks" and "Redis Enterprise: lag-aware availability", and itsAllSuccess/AnySuccessdescriptions now say how an all-Inconclusiverun is reported.Open
clusters do not require client certificates on 9443). Loading and wiring are tested.
passwords from unfiltered reads; a lag failure is indistinguishable by body from "not Active-Active"; the
lag check is blind for about a minute when a link breaks; and every other client's defaults (lag check on,
5000 ms, no Active-Active check) would report every non-Active-Active database as permanently unhealthy
(inferred from their code).
network_latencybug in the fault injector (its cleanup thread loses its SSH session, leaving the delayin place) was reported to
redis-developer/cae-client-testing.Overlap to be aware of
notes/readme.mdalso appears in #3241,which introduced the
notes/convention. Whichever merges second gets a one-line conflict in its Topicslist.