Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9ade061
Investigate Redis Enterprise lag-aware availability checks
mgravell Sep 17, 2026
4d5a239
Record what a Tunnel API for the management plane should look like
mgravell Sep 17, 2026
e9014f0
Real clusters and the fault injector, and a correction
mgravell Sep 17, 2026
8120673
We already have a fault-injector tier; it answers several of the open…
mgravell Sep 17, 2026
b9ab0f5
Add the plan, blocked on #3191
mgravell Sep 17, 2026
61de10c
Step 0 is cleared; note that step 1 needs the machine with cluster ac…
mgravell Sep 18, 2026
4855d55
Pin the cluster REST certificate per host rather than chaining to the…
mgravell Oct 6, 2026
d673c90
Step 1 measured: the lag check is Active-Active only, and does what i…
mgravell Oct 6, 2026
0420722
Correct the client defaults: Lettuce, Jedis and redis-py all default …
mgravell Oct 6, 2026
cf64be1
Measure what a probe can discover: not Active-Active over RESP, db_vi…
mgravell Oct 6, 2026
ad4b315
TLS reuse, the cross-client survey, and the local form routed by data…
mgravell Oct 6, 2026
495725e
The local form flips under paused sync exactly as the database form does
mgravell Oct 7, 2026
bfb7844
Sketch the lag-aware API for review
mgravell Oct 7, 2026
19c7092
API sketch: System.Text.Json decided; open questions compared with ot…
mgravell Oct 7, 2026
f330ffb
Partition test: the lag check is blind for a minute, then fails on bo…
mgravell Oct 8, 2026
796ca5e
AllSuccess: a run of only inconclusive probes is not a pass
mgravell Oct 8, 2026
9e2d07e
Failback health checks: ask whether a member has caught up before mov…
mgravell Oct 8, 2026
2ba0a30
Notes: core change implemented behind SER011 (SER007 is retired)
mgravell Oct 8, 2026
eb44fdd
HealthCheck.LagAware: ask Redis Enterprise whether a member is availa…
mgravell Oct 8, 2026
2c647d7
Notes: probe implemented; RawConfig is not public after all
mgravell Oct 8, 2026
602d94a
LagAware: ask the cluster name, and never follow redirects
mgravell Oct 8, 2026
3950797
Notes: management routes redirect to the master's internal address
mgravell Oct 8, 2026
6d4af4c
Fault-injector scenario: refuse to fail back onto a stale Active-Acti…
mgravell Oct 8, 2026
8e135d1
Notes: tier-3 scenario test passing
mgravell Oct 8, 2026
3fe2208
LagAware: client certificates for the REST call
mgravell Oct 8, 2026
9b3b1a5
Failback: a member chosen by the liveness fallback is provisional
mgravell Oct 9, 2026
fa5b8ab
Notes: fallback choice is provisional, as decided by client parity
mgravell Oct 9, 2026
4fce481
Move the lag-aware log message to a free event id
mgravell Oct 9, 2026
161539e
AnySuccess: a run of only inconclusive probes is not a failure
mgravell Oct 9, 2026
059f7dc
Move the lag-aware log message to a free event id (again)
mgravell Oct 13, 2026
ec12130
Failback: a zero FailbackDelay clears a failure in the same clock tic…
mgravell Oct 13, 2026
c5c819b
Ship lag-aware failback without an experiment
mgravell Oct 13, 2026
2c0540b
Notes: no experiment
mgravell Oct 13, 2026
2c8c9c3
Notes: drop the SER011 history
mgravell Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="System.Runtime.InteropServices.RuntimeInformation" Version="4.3.0" />
<PackageVersion Include="System.IO.Compression" Version="4.3.0" />
<PackageVersion Include="System.IO.Pipelines" Version="10.0.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<!-- note that this bumps System.Buffers, so is pinned in down-level in SE csproj -->
<PackageVersion Include="System.IO.Hashing" Version="10.0.12" />
<!-- for RESPite -->
Expand Down
88 changes: 85 additions & 3 deletions docs/Failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Because the policies are immutable, there is no question of whether a change "ta
| `RetryPolicy` | `MultiGroupOptions.RetryPolicy` | *(none; retry is applied per-database via `WithRetry`)* |
| `HealthCheckInterval` | `MultiGroupOptions.HealthCheckInterval` | *(none; it is the group's re-evaluation cadence)* |
| `FailbackDelay` | `MultiGroupOptions.FailbackDelay` | `ConnectionGroupMember.FailbackDelay` |
| `FailbackHealthCheck` | `MultiGroupOptions.FailbackHealthCheck` | `ConnectionGroupMember.FailbackHealthCheck` |
| `Weight` | *(none)* | `ConnectionGroupMember.Weight` |
| `SkipInitialHealthCheck` | *(none)* | `ConnectionGroupMember.SkipInitialHealthCheck` |

Expand Down Expand Up @@ -430,7 +431,7 @@ The probe policy determines how multiple probe results are evaluated to determin

#### HealthCheckProbePolicy.AnySuccess

The health check passes if **any** probe succeeds. This provides the most lenient evaluation:
The health check passes if **any** probe succeeds, and fails only if at least one probe failed and none succeeded. This provides the most lenient evaluation. A run in which every probe was `Inconclusive` reports `Inconclusive`, not `Unhealthy`:

```csharp
HealthCheck healthCheck = new HealthCheck.Builder
Expand All @@ -443,7 +444,7 @@ HealthCheck healthCheck = new HealthCheck.Builder

#### HealthCheckProbePolicy.AllSuccess (Default)

The health check passes only if **all** probes succeed. This provides the strictest evaluation:
The health check passes only if **no** probe fails and at least one succeeds. This provides the strictest evaluation. A run in which every probe was `Inconclusive` (the probe could not tell) reports `Inconclusive`, not `Healthy`:

```csharp
HealthCheck healthCheck = new HealthCheck.Builder
Expand Down Expand Up @@ -753,7 +754,88 @@ ConnectionGroupMember[] members = [

This guards against **flapping**: a member that is intermittently failing will keep pushing its "last failure" time forward, so it never satisfies the delay and stays out of rotation until it is genuinely stable.

> Implementation note: the failback check is pure tick math on the wall clock — the last-failure time and the cutoff (`UtcNow - FailbackDelay`) are both compared as raw `long` UTC ticks, so `DateTimeKind` never enters into it. (`DateTime.Ticks` and `TimeSpan.Ticks` share the same 100 ns unit.)
> Implementation note: the `FailbackDelay` comparison is pure tick math on the wall clock — the last-failure time and the cutoff (`UtcNow - FailbackDelay`) are both compared as raw `long` UTC ticks, so `DateTimeKind` never enters into it. (`DateTime.Ticks` and `TimeSpan.Ticks` share the same 100 ns unit.)

### Failback health checks

`HealthCheck` answers "is this member alive". Some deployments need a second question answered before traffic
moves *to* a member: "has it caught up". In an Active-Active deployment, a region that has just come back can be
reachable but stale, and failing back onto it reads (and builds on) data that is behind.
`ConnectionGroupMember.FailbackHealthCheck` (or the group default, `MultiGroupOptions.FailbackHealthCheck`) is
that second question:

```csharp
ConnectionGroupMember member = new("us-west.redis.example.com:6379", name: "US West")
{
Weight = 10,
// CaughtUpProbe stands for a probe of your own; see "Custom Health Check Probes"
FailbackHealthCheck = new HealthCheck.Builder { Probe = new CaughtUpProbe(), ProbeCount = 1 },
};
```

The rules:

- **It never removes the active member.** It is consulted only for members that are not active, and only
decides whether they may *become* active. Liveness stays with `HealthCheck`.
- **Only `Healthy` makes a member eligible.** `Inconclusive` (for example, the probe could not reach whatever
it asks) leaves the member ineligible, as does `Unhealthy`; neither marks it unhealthy.
- **A verdict is not kept while it cannot be acted on.** A member that is active, or not connected, is checked
afresh before it is next eligible.
- **If no connected member is eligible, selection falls back to liveness alone.** In a partition between
regions every member can be behind at once, and a group that refused to serve from any of them would turn a
consistency precaution into an outage. When the active member is lost, the best eligible survivor is
preferred, then the best live one.
- **A member chosen that way is provisional.** It is left for an eligible member as soon as one is
available, whatever their weights, and becomes an ordinary active member once it passes its own failback
check. Other clients never select a member that fails its lag check at all; this is the nearest
equivalent that still serves when every member is behind.
- **An explicit `TryFailoverTo(member)` overrides it**, as it does `IsUnhealthy`.

A probe that asks about the database as a whole, rather than one endpoint, can set
`HealthCheckProbe.Scope` to `HealthCheckProbeScope.Member`, so that it runs once per member per pass rather than
once per endpoint.

#### Redis Enterprise: lag-aware availability

For Redis Enterprise Active-Active deployments, `HealthCheck.LagAware(...)` asks the cluster's own
[database availability API](https://redis.io/docs/latest/operate/rs/monitoring/db-availability/) whether a
member is available and caught up. The only thing it needs is the cluster's management credentials:

```csharp
ConnectionGroupMember member = new("redis-14460.us-west.example.com:14460,password=...", name: "US West")
{
Weight = 10,
FailbackHealthCheck = HealthCheck.LagAware(new LagAwareOptions
{
Credentials = _ => new(new NetworkCredential("probe@example.com", secrets.Get("us-west-mgmt"))),
}),
};
```

What it does by default, and why:

- **The REST endpoint is derived** from the member's first configured endpoint: a database named
`redis-{port}.{cluster}` (the form Redis Enterprise assigns) is asked via `https://{cluster}:9443/`. The
cluster name matters, because only the cluster's master answers every route the check uses; other nodes
redirect to its internal address, which is usually unreachable from outside the cluster's network.
Redirects are never followed. Set `RestEndpoint` if the database is reached through a name of your own.
- **The database is found** by matching that endpoint against the cluster's databases, using only filtered
requests (unfiltered database listings include database passwords, even for read-only roles); set
`DatabaseId` to skip this.
- **The lag question is only asked of Active-Active databases** (`LagCheck = LagCheckMode.Auto`). Asked of
any other database, the server reports it unavailable regardless of tolerance.
- **No tolerance is sent**, so the cluster's own `availability_lag_tolerance_ms` applies; set `LagTolerance`
to override it. (Other clients default to 5000 ms; the server's default is 100 ms.)
- **"Could not ask" is `Inconclusive`, not `Unhealthy`.** A management-plane problem (REST API unreachable,
credentials rejected) never fails anything over; as a failback check it simply leaves the member
ineligible. A role with `management: db_viewer` is sufficient.
- **The management certificate is usually self-signed by the cluster**, so it will not validate against the
platform trust store; `options.TrustIssuer(path)` with the cluster's own certificate (or its issuer) works,
as it does for the data plane, but it is a separate setting from the database's TLS trust.

One limitation to plan around: measured on Redis Enterprise 8.0, when the link between regions breaks, the
lag check keeps reporting "caught up" for roughly a minute before it notices. A failback in that window is not
protected by it; a `FailbackDelay` of at least that long closes the gap.

### Anti-flap tiebreak in selection

Expand Down
Loading
Loading