Skip to content

Maintenance notifications: opt in, and receive (D2) - #3193

Draft
mgravell wants to merge 4 commits into
marc/maint-optin-serverfrom
marc/maint-optin-client
Draft

Maintenance notifications: opt in, and receive (D2)#3193
mgravell wants to merge 4 commits into
marc/maint-optin-serverfrom
marc/maint-optin-client

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

Stacked on #3191 (marc/maint-optin-server), which carries the toy-server half. Draft, like its base: nothing here is meant to land yet.

This is stage 1 of maintenance notifications ("smart client handoffs") in both directions - we ask for them, and we receive and report them. No reaction of any kind yet, deliberately, so a consumer can watch what its servers announce before any behaviour depends on it.

The opt-in (556b2017)

ConfigurationOptions.MaintenanceNotifications (maintNotifications), tri-state, gated [Experimental("SER010")] with docs/exp/SER010.md. The handshake sends CLIENT MAINT_NOTIFICATIONS ON pipelined next to CLIENT ID - a bare ON, so the server picks the endpoint type; deriving one ourselves is later work and a bare ON is explicitly valid.

Worth a reviewer's attention:

  • Enabled means required, not on. It rejects the connection when notifications cannot be delivered: a server that refuses, a server that answers HELLO 3 as RESP2, and a configuration that could never ask (Protocol = Resp2, or no HELLO). That last case diverges from the letter of the cross-client spec, which mentions only the error reply - but requiring a RESP3-only feature over RESP2 is a contradiction, and half-honouring it silently is what the mode exists to prevent. Auto is the best-effort mode and never rejects a connection.
  • The names are the cross-client ones, so a connection string ports between clients (our Enabled is go-redis's enabled; our Auto is its auto). A Disabled/Enabled/Demand spelling was built and reverted, because portability of the config string beat the clarity. The mitigation is documentation: the rejection warning leads the XML docs on both the enum member and the property, so it shows in the completion list rather than only on hover.
  • AzureManagedRedisOptionsProvider is Auto, pre-emptively. AMR does not emit these yet and is adding support concurrently; Auto is what makes announcing early safe, and it then starts working without anybody editing a connection string. Pending validation against a real AMR endpoint - being discussed on Toy server: support maintenance-notifications #3191. This is the one change here with a blast radius beyond us, and it is one property override plus one InlineData if it needs to come out.
  • Everything else defaults to Disabled. Most servers have never heard of the subcommand, and the server is not the only thing in the path: an unrecognized CLIENT subcommand is not guaranteed to be answered as politely by a proxy as by a server.

Receiving (dd8c507a)

Seven new PushKind members, dispatched in OnOutOfBand before anything reads element 1 as a channel name - because element 1 is a sequence number, which is the whole reason these could not be handled as pub/sub. Parsing lives in a new PhysicalConnection.Maintenance.cs; PushMaintenanceEvent is raised through the existing ConnectionMultiplexer.ServerMaintenanceEvent, so it already flows through IConnectionMultiplexer and MultiGroupMultiplexer.

  • The type decides whether a time element is expected, not the content. A single-slot SMIGRATING payload of "123" is indistinguishable from a duration by inspection, so content-sniffing would silently lose the slot list. There is a test for exactly that.
  • MOVING's placeholder forms - explicit null, ?, a zero port - all yield NewEndPoint = null with the raw text kept, never the answering server. Same reasoning as the unroutable-redirect work in Route from CLUSTER SLOTS, and give endpoints a lifecycle #3177.
  • The PushKind lookup is now case-insensitive throughout, not just for the new members: the pub/sub kinds are lowercase on the wire and these are uppercase.

Testing

MaintenanceOptInClientTests and MaintenanceNotificationTests, plus a suite-wide switch (REDIS_TESTS_MAINT_NOTIFICATIONS, mirroring the existing REDIS_TESTS_MIN_TIMEOUT_MS) so the whole suite can run with the opt-in on. Verified: the full net10.0 suite is identical with it at Auto and with it off. That is the point - against every OSS server we test on the opt-in is refused, and nothing else changes.

The two tests I would keep if I had to drop the rest: a malformed push frame is consumed and forgotten, with ten command round-trips afterwards proving the connection is still in sync rather than merely alive; and 50 notifications interleaved with 50 round-trips with nothing lost or mismatched.

Adds the client half of the maintenance-notification ("smart client handoffs")
opt-in: a tri-state ConfigurationOptions.MaintenanceNotifications, and the
CLIENT MAINT_NOTIFICATIONS ON that carries it, pipelined next to CLIENT ID.

The mode names are the prescribed cross-client ones, so a connection string
ports between clients - which makes Enabled mean *required* rather than merely
"on". That is easy to misread, so the warning leads the XML docs on both the
enum member and the property, where it shows in the completion list rather than
only on hover.

Enabled fails uniformly: a server that refuses, a server that answers HELLO 3 as
RESP2, and a configuration that could never ask (Protocol = Resp2, or no HELLO).
The last of those diverges from the letter of the spec, which mentions only the
error reply - but requiring a RESP3-only feature over RESP2 is a contradiction,
and half-honouring it silently is what the mode exists to prevent. Auto is the
best-effort mode and never rejects a connection.

Since the handshake is pipelined we don't know the negotiated protocol at write
time, so the request is speculative (as the redundant AUTH already is) and
ReconcileMaintenanceNotifications settles it afterwards, with every fact in
hand. The opt-in processor absorbs a refusal rather than routing it through the
common error path, which would raise ErrorMessage to the consumer for something
we asked for on their behalf.

Defaults stay Disabled globally: most servers have never heard of the subcommand,
and the server is not the only thing in the path - an unrecognized CLIENT
subcommand is not guaranteed to be answered as politely by a proxy as by a
server. AzureManagedRedisOptionsProvider is Auto pre-emptively, which is safe
precisely because Auto tolerates a refusal; pending validation against a real
AMR endpoint.

Also: REDIS_TESTS_MAINT_NOTIFICATIONS lets the whole suite run with the opt-in
on, mirroring REDIS_TESTS_MIN_TIMEOUT_MS. Verified as a no-op at Auto against
real servers that refuse it, which is the point. The toy server now matches
keywords case-insensitively (we send ON, go-redis sends on, a real server takes
both), and InProcessTestServer.MaxProtocolVersion can answer HELLO 3 as RESP2.
The other half: seven new PushKind members for the notification families, and a
parser that turns them into a PushMaintenanceEvent on the existing
ConnectionMultiplexer.ServerMaintenanceEvent. Observation only - nothing reacts
to these yet, deliberately, so a consumer can watch what its servers announce
before any behaviour depends on it.

These are dispatched in OnOutOfBand *before* anything reads element 1 as a
channel name, because element 1 is a sequence number rather than a channel:
that is the whole reason they could not be handled as pub/sub. A frame we
cannot read is consumed and forgotten rather than falling through to the
command matcher, where it would take a reply belonging to something else -
tested by following a malformed push with command round-trips that prove the
connection is still in sync, not merely alive.

Two decisions worth naming:

- The type decides whether a time element is expected, not the content. A
  single-slot SMIGRATING payload of "123" is indistinguishable from a duration
  by inspection, so content-sniffing would silently lose the slot list. A
  notification that omits its time, or adds one where the contract says there
  is none, is still accepted.
- MOVING's placeholder forms - explicit null, "?", and a zero port - all yield
  NewEndPoint = null with the raw text kept, never the answering server. Same
  reasoning as the unroutable-redirect work: an address that cannot be dialled
  must not be replaced by a guess.

The PushKind lookup is now case-insensitive throughout rather than only for the
new members: the pub/sub kinds are lowercase on the wire and these are
uppercase, and one lookup that tolerates both beats two lookups.

The shard-id and slot payloads are carried through as opaque strings. Nothing a
client is asked to *do* depends on which shards are involved, and parsing a
field the contract does not pin down would be inventing a model.
@mgravell
mgravell force-pushed the marc/maint-optin-client branch from dd8c507 to d615d35 Compare August 26, 2026 08:20
Three related pieces of configuration work, all in service of "when should SCH be
on?".

**MaintenanceNotifications is no longer nullable.** It followed Protocol, where
null means something real ("no preference, let the library decide"). Here there is
no third state - Disabled *is* off - so it now follows the convention almost every
other option uses: non-nullable, falling back to the provider. That also removes
three `?? Disabled` coalesces.

**RedisCloudOptionsProvider**, matching the Cloud domains, with Auto. It is
deliberately *not* a copy of the AMR provider, though the deployments look
similar:

- GetDefaultSsl stays false. AMR is TLS-only so assuming TLS there is safe; Redis
  Cloud enables TLS per database and plenty are plaintext, where guessing would
  fail their connect outright.
- DefaultVersion stays at the library default. 7.4 is AMR's *floor*; Cloud still
  offers older versions per database, and claiming a version we do not have
  unlocks commands the server will reject.

It does share what is about being a proxied, hosted deployment: RESP3 (which the
feature requires), no configuration-broadcast channel, and fail-soft connect.

**Providers can be named in a configuration string**, as `defaults=amr`,
`defaults=rediscloud`, `defaults=azure` or `defaults=enterprise`. The on-premise
case is why: an Enterprise cluster has whatever DNS its operator gave it, so
IsMatch can never recognize it, and until now selecting a provider meant writing
code - impossible for an application configured by a connection string. It also
covers a hosted deployment reached behind private DNS or a proxy, where the
endpoint stops looking like what it is. Hence RedisEnterpriseOptionsProvider,
which matches nothing and exists to be asked for.

Resolution is by name against registered providers only, never by type name: a
configuration string that could name an arbitrary type would be a way to have one
loaded, and would defeat trimming.

Round-tripping needed care. The Defaults getter memoizes an inferred provider into
the same field an explicit set writes, so merely *reading* the property would
otherwise make an endpoint-derived guess indistinguishable from a decision - and
re-parsing the string would then pin it. So a flag records that the caller chose
it, and `defaults=` is written only when it was chosen *and* the provider has a
name; unnameable custom providers behave like custom tunnels and simply do not
serialize. Clone copies the field rather than the property, which is what keeps
that distinction intact.

Note one deliberate behaviour change: ToString() on options with an explicitly-set
inbuilt provider now includes `defaults=<name>`, where before that choice was
silently dropped. DefaultsProviderProtocolNotSerialized is updated to assert both
halves - that provider *values* still never leak, and that the provider *choice*
now round-trips.
For logs: a provider should print as "amr" rather than as a namespace-qualified
type name, and an unnameable one falls back to the type as before.

Note serialization deliberately does not route through this - it tests Name
directly, because ToString never returns null and an unnameable provider must not
end up in a configuration string. Display name and round-trippable identifier are
not the same thing here, even though Tunnel conflates them behind IsInbuilt.
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