Skip to content

[blocked on #3191] Lag-aware availability checks for geo-redundant failover: findings and plan - #3242

Draft
mgravell wants to merge 6 commits into
mainfrom
marc/lag-aware-availability
Draft

mgravell wants to merge 6 commits into
mainfrom
marc/lag-aware-availability

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

Important

Blocked on #3191, and not only a planning branch.

This currently adds notes and no product code. It is opened as a draft now so the investigation is
reviewable while the dependency clears — this branch becomes the feature branch once
#3191 merges, with the
implementation landing on top of these same commits rather than starting again elsewhere.

The plan is a proposal. Where it leans, it says so; where the evidence does not decide, it leaves
the question open rather than inventing an answer.

What this is

Investigating whether Redis Enterprise's
lag-aware database-availability API
belongs in the geo-redundant failover health checks in src/StackExchange.Redis/Availability/
(currently behind SER007), using
Lettuce's LagAwareStrategy
as the reference implementation.

file what it is
notes/lag-aware/findings.md the research — evidence, with sources
notes/lag-aware/plan.md the sequence that follows from it — proposed, blocked on step 0
notes/readme.md the notes/ convention (see "overlap" below)

Findings worth surfacing

  • This is a cross-client feature, not a Java curiosity. Lettuce and Jedis have
    LagAwareStrategy; redis-py has LagAwareHealthCheck. It is the client-side geographic failover
    family. The check is not liveness — extend_check=lag asks whether a replica is synchronised
    enough to be a safe failover target
    , which is what stops a failback onto a region that is
    reachable but stale.
  • Our abstractions already fit, nearly one-to-one. HealthCheckProbeAbstractHealthCheck,
    AllSuccess/AnySuccess/MajoritySuccessHEALTHY_ALL/ANY/MAJORITY, Ping default,
    circuit breaker, per-member configuration. 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 exactly what
    a per-cluster REST endpoint needs.
  • The defaults disagree across clients. 100 ms from the cluster default, the REST documentation's
    explicit recommendation, and redis-py — but 5000 ms in Lettuce. Fifty times more permissive
    than its own documentation advises. The plan measures rather than copies.
  • We are better placed than Lettuce on one point. HealthCheckResult has Inconclusive;
    HealthStatus has two states, and 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 traffic perfectly. Proposed as a deliberate, documented deviation.
  • src/ may need no changes at all. The probe is subclassable, RawConfig and
    ConfigurationOptions.Tunnel are public. The only candidate core change is an optional Tunnel
    addition so the management plane honours tunnels — and if we do that, it should return a stream,
    composed into SocketsHttpHandler.ConnectCallback by the consumer, so no System.Net.Http type
    enters the public API.

Corrections made while investigating

Recorded rather than quietly fixed, because the reasoning is the useful part:

  1. I assumed a real cluster could not be made to show "reachable but stale" on demand, making fakes
    the only route to the scenario that matters. Wrong twice: Redis ships a fault injection service
    for exactly this, and we already have a test tier built on it — in Server-native maintenance notifications: opt in, react, hand off, and recover when nothing is announced #3191.
  2. I framed the HTTP dependency as a significant cost. It is not: System.Net.Http needs no package
    reference on any target we ship. System.Text.Json is the only genuinely new dependency, and it
    is avoidable. So "where does this live" is a judgement call about scope, not a technical
    constraint.

Why step 0 is "wait"

#3191 carries tests/StackExchange.Redis.FaultInjector.Tests, and with it ClusterRestClient
already a Redis Enterprise REST client on 9443 doing Basic auth, CA pinning and
GET /v1/bdbs?fields=… — plus a FaultInjectorClient that can be asked which lag-inducing effects
a deployment supports. Starting now would mean writing a second REST client against the same cluster
and reconciling them at merge, for a feature whose implementation is smaller than the client that
tests it.

That tier also settled three things empirically that I had only from documentation: the management
certificate is self-signed per environment (so the probe needs CA configuration, not ambient
trust), auth really is Basic, and the bdbs listing returns the requested fields.

The one thing that can start earlier is validating the availability API against a real deployment,
because it produces notes rather than code — and #3191's own summary is the argument for doing it
first: "the specifications are prose, the payloads were never published, and nearly every assumption
I started with was wrong in some way that mattered."

Deliberately undecided

  • Where the code ships — core, a separate Enterprise package, or documented extension point. The
    plan leans toward a separate package and says why, but this is a scope judgement.
  • The tolerance default — measured in step 1, not copied.
  • Whether the Tunnel addition happens at all — separable; the probe works without it.

Overlap to be aware of

notes/readme.md also appears in
#3241, which introduced the
notes/ convention. Whichever merges second gets a one-line conflict in its Topics list; the rest of
the file is identical.

Draft until #3191 lands.

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.
@mgravell
mgravell force-pushed the marc/lag-aware-availability branch from cce049c to 916a563 Compare September 18, 2026 04:34

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.

1 participant