Skip to content

v4: the 4.0 core, surface and alpha branch (tracking) - #3262

Draft
mgravell wants to merge 830 commits into
mainfrom
v4
Draft

mgravell wants to merge 830 commits into
mainfrom
v4

Conversation

@mgravell

@mgravell mgravell commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Tracking PR, not for immediate merge. v4 is the long-lived 4.0 branch; this PR exists to show its
diff against main and to collect review. It supersedes #3219, which was the spike this grew from.
When it lands it must be a real merge, not a squash: main is merged into v4 as it moves, so the
final merge stays small. Pending work and open decisions are in design/v4-ledger.md.

What 4.0 is

A new core, with the old one deleted. Message, ResultProcessor, PhysicalBridge,
PhysicalConnection and RedisDatabase are gone. Commands are rendered by an interpolated-string RESP
writer and sent through executors over the RESPite transport. Reconnect, backlog, resubscribe, redirects,
maintenance events, sentinel and logging (shipped event ids preserved) were ported and checked against
the old core before it was deleted.

IDatabase stays, served by the new core. Existing code keeps compiling and working; see
docs/LegacyApi.md. The old API is not deprecated.

A grouped, async-first command surface alongside it:

await db.Strings.SetAsync("k", "v");
using var members = await db.SortedSets.RangeByScoreAsync(key, 0, 10);   // a lease over the reply buffer

Commands are extension members on group types (Strings, Hashes, Keys, ...), so adding one - ours or a
third party's - is never a binary break to an interface. Replies can be leased rather than allocated, and
every call takes a CancellationToken.

Highlights

  • Less work on the IO loop. Commands are formatted on the caller's thread, straight into pooled
    buffers, and replies are parsed on the caller's continuation rather than by the reader. The core's IO
    loop now only frames bytes in and out, so one slow or large reply no longer holds up everyone else's.
  • A much more efficient API for ad-hoc commands and extension libraries (NRedisStack and similar).
    Instead of building object[] argument arrays for Execute/ExecuteAsync and picking through a
    RedisResult, a library writes ctx.SendAsync<T>($"FT.SEARCH {index} {query}") with a typed reply
    handler, getting the same zero-copy formatting, cluster slot routing, key prefixing and caching as the
    built-in commands, and can hang its own command groups off the same targets as extension members.
  • Client-side caching (ConfigurationOptions.ClientCache, RESP3, opt-in) driven by the library itself
    (CLIENT TRACKING), keyed by the rendered frame, with invalidation that cannot race the send; in a connection
    group, our own writes reach every member's cache. See docs/ClientSideCaching.md.
  • Batches and transactions on the new surface (SER014): db.BeginBatch() / db.BeginTransaction()
    return structs that are ordinary keyspace targets, with explicit ExecuteAsync, discard-on-dispose, and
    conditions via the existing Condition type.
  • Sync without the thread pool: Blocking() contexts send and wait on the calling thread, so a group
    method returns an already-completed ValueTask; the synchronous IDatabase methods are built on it, and
    so can a library's. DedicatedThreads connections connect and handshake synchronously, so on Linux their
    sockets never need a pool thread either.
  • For library authors: one generic accessor per group, Blocking() for sync forms, IDatabaseAsync.AsTask(...)
    for Task forms (async state and observed faults), and context.Multiplexer for connection-level settings
    such as AddLibraryNameSuffix. See docs/Extending.md.
  • Cluster: routing by CLUSTER SLOTS, batches split per slot, MOVED/ASK handled in the executor.

Compatibility

  • Package and assembly version 4.0; prereleases publish from this branch as 4.0.N-alpha.
  • Newer surfaces are gated behind [Experimental] IDs (SER011, SER012, SER014; see docs/exp/).
  • Additions to the IDatabase family are deliberate and expected in a major version; removals and
    signature changes to shipped members are not.

Status

The full suite runs on Ubuntu (net10.0) and Windows (net10.0, net481); CI is green at the branch head (3cf425d).
Latest prerelease: 4.0.127-alpha (2026-10-12). Intermittent failures are listed under Watch, with detail in the ledger.

Pending

Mirrors design/v4-ledger.md (last updated 2026-10-13), which has the detail; ticked here as items close.

Decisions

Work

  • Run ActiveActiveCacheScenarioTests against a real Active-Active deployment - written and compiled, never executed (needs the fault-injector environment)
  • Two-core leftovers: Subscription._subscribed/IsSubscribed/AnySubscribed; ~30 ConnectionsIfCreated?. null-tolerances; topology flowing both ways between RespTopology and ServerSelectionStrategy
  • Subscription re-aim waits for its in-flight send to fail (~2s on Windows)
  • An abandoned connect (timeout) keeps its socket in SYN-SENT until the OS gives up - ConnectAsync is called without the token (default mode only)
  • Retry: stale comments in RetryDatabase.ExecuteAsync and RespRetryExecutor
  • Prose still says "the new core" in comments and docs
  • Keep absorbing main by merging (ongoing; the final landing must be a real merge)
  • Stale design notes on the cache - corrected in design/client-side-cache.md (378a103)
  • Client-side cache: PFCOUNT is cacheable and retryable - its write is internal bookkeeping (d59d4e2)
  • Sync Send<T> over a pre-built frame defaults its handler, as the async twin does (97ec53a; from the NRedisStack port)

Watch (flakes, not yet reproducible)

  • GetServerByKeyMemoization (RESP3, Windows net481) - speculative fix d45eadf; not seen in the 60 CI runs since 2026-10-09
  • "Nothing inbound" timeouts in RespCacheChurnTests - 2026-10-09, and again 2026-10-10 (Ubuntu) and 2026-10-12 (Windows net10.0); take a dump mid-stall next time
  • BacklogTests.TotalOutstandingIncludesBacklogQueue and SentinelTests.AbortOnConnectFailFalseRecoversOnceSentinelsAppear - intermittent, pre-existing
  • net8.0 cluster tests timing out at 10s under the full suite
  • QueuedResultTests.RetryTransactionFireAndForgetSharesOneCompletedTask (once)
  • MaintenanceOptInServerTests.SequenceIdsAdvanceAndCanBeRepeated fails when its class runs alone
  • CancellationTests.WithCancellation_CancelledToken_ThrowsOperationCanceledException - a race in the test (Task.WaitAsync returns an already-completed task as-is); should cancel through the API's own token
  • Known flakes: TouchIdleTime, RespAggregateTiming, ADeferredWalkNeedsNoStorageAtAll, and seven single CI occurrences on 2026-10-10..12 (listed in the ledger)

Backlog (after the alpha)

  • Client-side cache across a geo/active-active failover - our own writes stamp every member's cache, so a failback cannot serve a value we replaced; an inactive member is kept honest by the heartbeat consistency checks groups force on (746a065; live run under Work)
  • Alternative client-side-cache invalidation sources - keyspace notifications (__keyspace@<db>__:*; needs notify-keyspace-events, no RESP3 requirement) and explicit pub/sub (an application-defined channel the writer publishes to), for deployments without usable CLIENT TRACKING (proxies, older servers, some managed offerings)
  • ... and with it the rest of the cache opt-in/out story: per multiplexer and per command, and what happens when the chosen source is unavailable (refuse loudly, as RESP2 + cache does today, or degrade to no caching)
  • Client-side cache: public counters / metrics (hits, misses, stored, refused, invalidations, stale-served) - internal today
  • Client-side cache performance, still to explore: the gap to Lettuce's 79% hit rate (model frequency-vs-victim admission and second-chance eviction first), typed parsing on the cached miss, per-send route resolution in CanWritePreamble, RespKeyTable node lifetime, the parked builder-inlining patch
  • Replace the method-replaying retry/prefix/group decorators with executor decorators (batches through a retrying executor, sync, non-context inners to settle first)
  • Operation pool size at batch depth (128 slots vs thousands in flight) - the lever the batch profile points at; then batch/transaction buffer packing
  • Unobserved faults on the new ValueTask surface (non-pooled async ValueTask only; low priority)
  • Performance: remaining ideas (each single-digit percent; parked)
  • Trusted-callback completion: inline completion measured and ruled out as a default; the one variant left is batching pool wakes per parse pass
  • Non-nullable AppendFormatted(long)/(double) overloads (NRedisStack; needs an overload-resolution sweep)
  • .NET 11 TFM: try runtime async; prototype caller-driven TLS (TlsBufferSession); run the suite and RespFest on 11 early

…er is deleted

`IServer.StringGet(int db, key)` now reads through `Strings.GetAsync` on a context
moved to the database the caller named - which is also what puts the `SELECT` where it
belongs, since a server context carries no database of its own.

And `GetMemoryPurgeMessage` is **deleted** rather than ported. Nothing in `src/` used it
any longer - `MemoryPurge` went to the context surface earlier - so it was production
code kept alive for a test to look at, which is the thing these ports are supposed to
remove rather than preserve. Its assertions moved onto the request the group method
issues, joining the other ported categories: `MEMORY` as a whole reads, so `PURGE` was
once retried as a harmless read, and the node-scoped bit has to survive or a retry goes
to a server that was never asked. The caller-override case moved with it.

Both ported members were **untested** - every `StringGet` in the suite is the
`IDatabase` one, and the purge builder was only ever reached by the retry test - so each
is now pinned in the parity suite. A port whose only route is the one nobody exercises
is a port nobody has checked.

`RedisServer.cs` is at 33 `Message.Create` sites from 68. 26 are `SENTINEL`, which keeps
its own bridge, so the real remainder is 7: `SCAN`'s five spellings, `CLIENT KILL` (its
filter wants modelling rather than forwarding a token list), the `CLUSTER NODES`/`SLOTS`
call sites (the builders stay, because `AutoConfigureAsync` and the multiplexer use
them), and `MakePrimaryAsync`'s ordered sequence - `REPLICAOF`/`SLAVEOF`, the
tie-breaker `GET`/`DEL`, the reconfigure `PUBLISH` - which are not `IServer` members so
much as steps in a failover.

Default suite 11,224 passing with one timing rotator; both flags 11,223 with the
`ReconnectRetryPolicy` pair and four rotators. All six pass in isolation (545 tests
across their classes). CI build clean.
… the right socket

The last subscription shape that was staying on the shipped path now moves, and it did
not need the "send reports where it finished" capability I had assumed. Two things
close it, neither a new executor contract.

**The redirect has to be followed onto the SUBSCRIPTION socket.** `TryFollowRedirect`
resolved every target through `_forEndpoint`, which is the ordinary connection - so
following a redirected `SSUBSCRIBE` the obvious way would fix the routing and poison
the connection, because under RESP2 a subscribe on the ordinary socket puts it into
subscriber mode and it then refuses everything but (un)subscribe, PING and QUIT. There
is now a second resolver, used for the six subscriber-mode commands - `UNSUBSCRIBE`
included, because a redirected one still has to reach the socket the subscription is on
or it unsubscribes something somewhere else. Under RESP3 both resolvers answer the same
executor, so this costs nothing there.

**Where it landed is re-resolved from the SLOT afterwards, not reported by the send.** A
`-MOVED` both moves the subscription and teaches this core where the slot went, because
`OnSlotMoved` updates the map on the way through - so the slot's owner afterwards IS
the answer. `RespNewCore.EndpointForChannel` asks it; a channel with no slot answers
null, meaning "where it was aimed". That is why the decline could be lifted without
plumbing a new channel back from the executor, which is the thing I had written down as
the blocker.

`ClusterShardedTests.SubscribeToWrongServerAsync` and
`KeepSubscribedThroughSlotMigrationAsync` pass - 6 cases, none skipped - as does the
whole cluster/redirect family twice over (204 tests).

What is left of D2.5 is `IdentifyEndpointAsync` alone, and it is the one member the slot
trick genuinely cannot serve: it asks about an arbitrary channel rather than a routed
one, so it does want the send to report where it finished.

Default suite 11,226/0. Both flags: the `ReconnectRetryPolicy` pair plus
`ClusterTests.MovedProfiling`, which has rotated all session and passes in isolation.
CI build clean.
`IdentifyEndpointAsync` did not want the "send reports where it finished" capability
either. The shipped version reads the endpoint off the connection its `PUBSUB NUMSUB`
reply arrived on, which is whichever one routing chose - so choosing before the send
and answering that is the same fact by a shorter route. Safe because `PUBSUB NUMSUB`
is keyless and node-local and therefore cannot be redirected, which is exactly what
made the SUBSCRIBE case hard and this one easy. The round trip still happens: it is
what makes the answer an observation rather than a guess.

**And moving it exposed a defect in the slot resolution from the previous commit.**
`EndpointForChannel` computed the slot from the channel's own bytes, where the server
routes by the name it RECEIVES - the prefixed one. So with a channel prefix configured,
a subscription landed on one node and was recorded against another. It only surfaced
once `IdentifyEndpoint` moved and the two stopped agreeing by accident;
`ClusterTests.ClusterPubSub(withKeyPrefix: true)` named it in two lines
(`Expected: 127.0.0.1:7001, Actual: 127.0.0.1:7002`). It now asks
`ServerSelectionStrategy.HashSlot(in RedisChannel)`, which already handles the prefix
and `IgnoreChannelPrefix` - borrowed rather than re-derived, which is the rule I should
have followed instead of hand-rolling a slot.

**D2.5 is done.** Publish, subscribe and unsubscribe for every channel shape - literal,
pattern, sharded, key-routed, multi-node - the subscriber ping, the
re-subscribe-on-reconnect, and the endpoint identity all travel on this core under the
flag. That is the whole of the subscription bridge's reason to exist.

Both flags: **exactly 2** - the `ReconnectRetryPolicy` pair, which is not answerable
while both cores consult one policy object and is assertable at D2.8 - with 11,228
passing. Default suite 11,225 with one timing rotator that passes in isolation. CI
build clean.
…re is its shape

Measured rather than assumed, because "subscriptions have moved, so delete the bridge"
looks like a one-liner and is not. Not activating the shipped subscription bridge under
the flag, plus relaxing the "fully established" gate so a connect does not wait for a
leg nobody dials, gives **63 failures** across the pub/sub, handshake, cluster and
config families. Reverted; the chain is now written down so the next attempt starts
from it:

- The subscribe path DECLINES a subscription that would share the ordinary connection,
  because this core picks its socket when a send is composed and a later downgrade can
  move it - issue #3154. That decline was only ever safe because the shipped bridge was
  there to take those subscriptions.
- Remove the bridge and the decline has nowhere to drain: the fall-through becomes a
  subscribe that goes nowhere, which `ClusterTests.ClusterPubSub` reports as an empty
  subscribed endpoint.
- Remove the decline too, so this core takes everything, and the shared-socket path goes
  live for the first time - which is where the other 63 come from.

The pieces to close it are the ones D2.5 already built: `SubscriptionEndpoint` shares
only on KNOWN RESP3, `IsSubscriptionConnected` stops answering yes for a shared socket
once the negotiated protocol drops, and the establish-time `EnsureSubscriptions`
re-places the subscription on a dedicated one. What is missing is making that chain hold
for a subscription composed before a downgrade and written after it - the narrow window
#3154 names. 63 failures is the measurement of how much of the suite depends on getting
that right rather than nearly right.

No source change: this commit is the map. HEAD is the verified D2.5 state - both flags
at exactly 2, the `ReconnectRetryPolicy` pair.
…eded

`origin/main` had drifted by two commits; merged rather than left to accumulate, which
is cheaper now than later and keeps provenance. No conflicts. The valuable part is that
BOTH needed a counterpart on this core - a fix to the shipped path is not a fix to this
one, and neither gap is visible from main.

**#3254, the configuration channel under RESP3.** The shipped fix subscribes the
BRIDGE's interactive connection. Under the flag `IDatabase.Execute("CLIENT", "ID")`
names THIS core's connection, which was not subscribed - `ClientKillTests` caught it as
`Normal` where `PubSub` was expected. Covered today, because the broadcast still lands
on the bridge; at D2.8 nothing would be subscribed and the bug returns. This core now
subscribes its own RESP3 connection, prefix applied, registry bypassed as the shipped
one does. Two orderings are load-bearing: after the handshake and on the NEGOTIATED
protocol, and after `OnPush` is wired - under RESP3 a subscribe confirmation IS a push,
and one arriving before the dispatcher is dropped as unrecognised, which presents as the
connection timing out in its own backlog.

**#3250, TLS/SNI host names.** `RespTransportFactory.AuthenticateAsync` carried its own
copy of the rule that commit replaced. An endpoint that already carries a DNS name must
use ITS name for SNI; only a non-DNS endpoint infers one. Cluster shards over TLS/SNI
are what breaks otherwise - every shard presented the same inferred host, so every shard
but one fails validation. Now `config.ResolveTlsHostName(endpoint)`, shared. That file's
own remarks warn against two copies of a security decision while this was one.

**One known failure, latent in D2.5's work rather than caused by the merge, and open:**
`ClusterTests.ClusterPubSub(sharded: false, withKeyRouting: false, withKeyPrefix: true)`
under both flags receives 20 messages for 10 published, while the publishing node
reports one subscriber each time. Established: it passes at the pre-merge commit
(verified in a worktree) and reproduces with the config-channel counterpart disabled, so
the trigger is the role repair - which sets `IsReplica`, and on this branch that setter
publishes the role and asks for a reconfigure, so topology applies now cascade. The
shape that fits is two of our connections subscribed on two different nodes, since a
non-sharded PUBLISH in a cluster is broadcast to every node while the publishing node
counts only its own: a subscription record moved endpoints while the old socket stayed
subscribed. `RemoveIncorrectRouting` does that unsubscribe-before-replace for key-routed
channels only, and this channel is not key-routed. Next step is instrumentation rather
than another guess; the notes carry all of it.

Default suite 11,245/0. Both flags: the `ReconnectRetryPolicy` pair, this one, and five
rotators that pass in isolation. CI build clean.
Fixes the one failure the main merge exposed, and it was a window rather than a leak.

**Diagnosed by instrumentation rather than guessed at**, after two wrong theories.
Tracing both delivery entry points showed every delivery arriving on THIS core - no
bridge deliveries at all, which killed the "both cores subscribed" idea. Tracing the
out-of-band path by socket then showed subscription sockets at ALL THREE cluster nodes
receiving. A non-sharded PUBLISH in a cluster is broadcast to every node, so each of
those delivers while the publishing node counts only its own subscriber: twenty
deliveries for ten publishes, with a reported count of one.

**The cause is the in-flight window D2.5 created on purpose.** The endpoint is recorded
only on CONFIRMATION, so a subscribe that never landed stays retryable - but
`EnsureSubscribedToServer` guards with `if (IsConnectedAny()) return 0`, and in that
window the answer is "not live". Every ensure arriving in it starts ANOTHER subscribe,
and for an unrouted channel `SelectServer` deliberately spreads, so each lands on a
different node. This core's establish-time re-subscribe then feeds the loop, which is
how it reached three nodes rather than two.

The merge did not cause this. #3254's role repair sets `IsReplica`, which on this branch
publishes the role and asks for a reconfigure, so topology applies now cascade - and
that opened the window often enough to see. It was latent before, and would surface on
any cluster that reconfigures while a subscribe is in flight.

The fix is one clause - `IsConnectedAny() || HasSendInFlight` - and it is the same
conflation the ownership field had to split earlier, in the other direction: **"not live
yet" and "nobody is doing anything about it" are different questions.** `IsHeldByNewCoreOn`
already consulted the in-flight endpoint for the ping gate; this is the other reader that
needed it.

Both flags: back to the `ReconnectRetryPolicy` pair plus `ClusterTests.MovedProfiling`,
11,247 passing. Default 11,244 with the known timing rotator. Three consecutive clean
runs of the cluster, pub/sub, keyspace and Issue1101 families. CI build clean.
First piece of the gate on removing the subscription bridge, and a direct port of the
shipped core's answer to #3154: `PhysicalBridge.WriteMessageInsideLock` hands a
subscriber-mode message to `ServerEndPoint.TryRerouteToSubscriptionBridge` when the
connection it is about to go out on turned out to be RESP2.
`RespEndpointExecutor.RerouteSubscription` is the same question in the same place.

Why it has to be the write rather than the compose: whether a subscription shares the
ordinary connection depends on the NEGOTIATED protocol, and a command can be composed
while RESP3 is expected and written after a reconnect has settled on RESP2 - at which
point writing it there puts the connection into subscriber mode and it refuses every
ordinary command afterwards. Inert while the bridge still exists, which is how it was
verified rather than assumed.

**And removing the compose-time decline on top of it was tried, and does not work yet -
for a reason that corrects my earlier framing.** With the decline gone,
`Resp3DowngradeTests` fails on TIMING, not correctness: nothing is poisoned, but
rerouting at the write has to DIAL the subscription socket first, and the in-process
server's transcript shows `PUBLISH => :0` landing before the re-subscribe arrives. The
shipped core does not pay that because it dials its subscription bridge the moment a
downgrade is detected - `OnFullyEstablished`'s
`else if (SupportsSubscriptions && Protocol > Resp2) Activate(Subscription)`.

So the two mechanisms do DIFFERENT jobs and both are wanted: choosing the right socket
when the protocol is already known is the fast path, and the reroute is the safety net
for the window where compose-time knowledge was wrong. The decline was never a crutch
for the reroute's absence, which is what I had written down before measuring it.

Next piece, now specified: dial this core's subscription socket proactively when a
connection establishes below the expected protocol, mirroring that `Activate`. Then the
decline can go, and then the bridge.

Both flags: the `ReconnectRetryPolicy` pair plus three rotators - `MovedProfiling` and
two `Resp3HandshakeTests` cases that pass 192/192 in isolation - 11,245 passing.
Default 11,244 with the known cache-sweep timing rotator. CI build clean.
… we hold one

A connection that comes up BELOW the protocol it expected has subscriptions about to be
re-placed onto a socket that does not exist yet, and a re-place that has to dial first
loses the race against whatever the caller does next. The shipped core reaches the same
conclusion in the same place, so dial it here on the establish.

Guarded on actually holding a subscription for that endpoint, which is the correctness of
it rather than a refinement: unconditionally, an endpoint whose subscriptions belong to the
shipped bridge gets a second socket and the channel is subscribed twice.

Also forget subscription records naming a replaced socket: a fresh socket carries nothing,
so a record naming it reads as "already subscribed" and suppresses the re-ensure.
Removes the compose-time decline that kept RESP3-shared subscriptions on the shipped
bridge - the gate on the subscription bridge, since while it stood the bridge had to exist
to place them. Four pieces make it safe and each was measured by removing it again; the
fifth had to be built, because the ordering the shipped core gets by accident (both bridges
reconnect on one heartbeat) has to be arranged here, where the subscription socket is
dialled lazily and the need for one is known only after the ordinary connection is warm.

SubscriptionsSettling is that happens-before: an endpoint observed to downgrade holds an
entry until its subscriptions are back on the wire, and publish waits on it, bounded. It
has to be released by the re-place rather than by the dial - a socket already opened once
returns from the connect long before the replacement runs its establish hook.

Also retires the now-dead WouldSubscribeOnItsOwnSocket, narrows the downgrade test's
mixed-traffic classifier to data commands (handshake probes on a dedicated subscription
socket precede subscriber mode and are legitimate), and makes the RESP2 separate-pubsub
assertion ask the server which socket is subscribed rather than assuming the bridge's.
A ServerEndPoint no longer builds a subscription PhysicalBridge at any protocol under the
flag: the subscription leg is the other core's, which dials and owns its own socket, and
with the share-the-connection decline retired every subscription goes there. All four
construction sites now answer with the interactive bridge or decline.

Stopping at "don't activate it" does not work and is worth the note in the design file:
IsSelectable creates the bridge it asks about and then requires it to be connected, so an
unactivated one makes every subscription command unselectable - no server is chosen, the
send reads as "nothing to do yet", and the subscribe silently does nothing. 70 failures,
nearly all a publish reporting no subscribers. Not needing it and not building it are one
change.

Five tests were counting this core's sockets or asking for two distinct connection ids;
each was already written as "RESP3 shares one connection" and now says "or the engine
flag". The sixth was a real gap: IsSubscriberConnected ignored a disabled SUBSCRIBE, which
the shipped path got free because such a bridge never connects.
Under RESP2 the shipped core subscribes the configuration channel as the last step of the
subscription bridge's handshake, so removing that bridge removed the client's only way of
hearing that the topology moved. This core's subscription socket now does it in the same
position - and, crucially, gets dialled at activation rather than lazily: the only
subscriber is the library itself on that socket's own establish, so a client that only
calls ReplicaOfAsync never touches this core and nothing would ever connect.

Dialled on the same KnowOrAssumeResp3 condition the shipped core uses before activating its
bridge, so a wrong guess costs a spare socket exactly as it did there.

Also replaces the tuple state object in the dial, which referenced System.ValueTuple and
failed SanityChecks.ValueTupleNotReferenced.

The connect gate stays open for now: making IsSubscriberConnected consult this core's
subscription leg was measured at 39 failures and a ten-minute run - the same connect-wait
hang as before - so ConnectUsesSingleSocket waits briefly for the count instead, keeping
the upper bound it exists to protect. Recorded in the design notes.
The share-the-connection decline was, in a cluster, also keeping key-routed channels on the
shipped path - so retiring it exposed three things that had never had to work here.

1. Re-resolve the landed endpoint only for SSUBSCRIBE. A key-routed channel has a slot, so
   the slot's owner can always be computed, but a plain SUBSCRIBE is not routed by it and
   never answers -MOVED: the subscription really is on whichever node it was sent to.
   SubscribeToWrongServerAsync(sharded: false) subscribes via a deliberately wrong server
   and requires it to still be there.

2. Act on an UNSOLICITED unsubscribe push. When a slot migrates away the node drops its
   shard channels and says so; there is no command of ours to match that to, so matching
   alone left the client believing it was subscribed on a node that had stopped delivering.
   Read from a copy of the reader, since the matching layer parses the frame from the start.

3. Scope the publish barrier to the downgrade re-place. The configuration-channel dial runs
   at activation, once per node, so in a cluster every publish waited on six of them and
   any one slow to establish held the lot - a five-second publish against the shipped
   path's eight hundred milliseconds.
…re does

Replaces the ad-hoc forget-and-re-ensure with the shipped routine itself. An unsolicited
SUNSUBSCRIBE - the node dropping its shard channels as a slot leaves - now calls
ResubscribeToServer via the OUTGOING node, which is the only one known to have the new
route, and returns Handled rather than leaving it to matching. "Unsolicited" is told apart
by there being no send in flight, which is what the shipped core asks of its own
outstanding commands.

ResubscribeToServer now sends via the owning core, which under the flag is not a refinement:
the shipped send resolves to the interactive bridge once there is no subscription bridge, so
under RESP2 it would put the connection carrying ordinary commands into subscriber mode.

Two further gaps this exposed:

- Rebind never passed forSubscriptionEndpoint, so TryFollowRedirect fell back to the
  ordinary executor and a redirected sharded subscribe put the target's ordinary connection
  into subscriber mode - the next publish there answered "only (P|S)SUBSCRIBE ... allowed in
  this context". The rule was written in TryFollowRedirect; it just had nothing to apply.

- ForgetSubscriptionsOn now skips a subscription with a send in flight. Such a subscribe is
  very often why the socket is being established at all, and its record is about to be
  confirmed rather than stale; forgetting it let the re-ensure place a key-routed channel on
  the slot owner instead of the server the caller explicitly chose.
… weakly from pushes

ActivateServer dials this core's subscription socket where it would have activated the
shipped subscription bridge, on the same KnowOrAssumeResp3 condition, because the
configuration channel is subscribed by connecting rather than by anyone asking: a client
that only calls ReplicaOfAsync never touches this core, so nothing would ever connect and
the broadcast reached nobody.

The establish-hook dial keeps its TryResp3 condition - dropping it made a plain RESP2 client
dial a second time, which RespSubscriptionConnectionTests counts.

OnPush now holds the multiplexer weakly. A subscription connection holds a standing read, so
the socket is rooted while open and anything strong reaching out of it keeps the multiplexer
alive; a push arriving after the multiplexer is gone has nowhere to go anyway. This is right
but not sufficient - the leak predates this change and is written up in design notes 9g with
what to chase next, along with the full before/after suite numbers and the measurement
showing that withdrawing the dial trades two failures for six worse ones.
A connection runs a read loop for as long as it is open, so its pending read keeps the
connection reachable - and every strong reference out of a connection then keeps the whole
deployment alive. A caller who abandons a multiplexer without disposing it never got it
collected.

The shipped core already says how this is meant to work: PhysicalConnection reaches its
bridge through a WeakReference and calls the property BridgeCouldBeNull to say so. This
core's connection needed the same on all three of its paths out - OnPush, Server, and the
redirect router - and the router was the one that mattered, being a lambda over an instance
method; the other two alone left the multiplexer rooted.

Each can go null while the connection is open, which every caller already handled: none of
them is known until the endpoint is resolved, so null was always possible, and "nobody is
modelling this any more" gets the same treatment as "not yet known".

GarbageCollectionTests.MuxerIsCollected now passes under both flags.
…annel

"Nothing has been sent, so nothing has dialled" stopped being true once this core dials at
activation for the configuration channel, so the test asserted its own precondition rather
than the mapping it exists to measure. Run with that channel off instead, which restores the
precondition and leaves the property alone.

Also clears SlotMapIsDrivenByTheSlotsView in the same batch - that one passes alone and its
in-batch failure reports a MOVED from a real server, so it was the in-process server sharing
port 6379 with the docker primary, not a routing fault.

Flagged full-suite failures are back to the baseline count of 5, with the subscription bridge
gone, the configuration channel carried, the multiplexer no longer rooted, and two of the
baseline five fixed. Numbers and membership in design notes 9g.
Adds Subscription.OnSubscribedViaNewCore, the missing half of OnSubscribedViaBridge:
ownership was otherwise only ever set by TrySendViaNewCore, so a caller that places a
subscription on this core's connection itself had no way to say so, and the registry asked
the wrong core whether it was live. The heartbeat reads that as a subscription nobody is
connected for and subscribes it again, which the server reports as two subscribers to one
channel.

ASubscriptionOnTheNewCoreReceivesDeliveries also has to use the multiplexer's own core
rather than the test fixture's: the registry asks server.Multiplexer.NewCore, so two
instances can never agree about ownership. The fixture's core is for asserting about
commands, where there is no shared registry to disagree with.

With the flag off the shipped bridge owns subscriptions and will always contest a
hand-placed one, so the test now skips there, stating why. It had been failing on the
shipped run for that reason; the shipped suite is now fully green.
A send to a node that has stopped accepting writes never completes, so the endpoint it
recorded was never cleared and HasSendInFlight answered yes for ever - after which every
EnsureSubscribedToServer returned "nothing to do" and the subscription could not be
re-placed at all. Bounded by the configured timeout.

Bounded by TIME rather than by asking whether the connection is live, which was tried and
is wrong: a subscription socket that is still dialling is not live either, so liveness
cannot tell "not up yet" from "never coming", and treating the first as abandoned reopens
the duplication window the guard exists to close.

Found while diagnosing RetirementUnderMaintenanceTests, which it does NOT fix: that one is
EndpointForSlot having no reachability filter where the shipped selector passes
allowDisconnected: false, so this core keeps aiming a slot at an owner it cannot reach.
Written up in design notes 9h as the next step, along with the causes ruled out.
EndpointForSlot asked the topology who owns a slot and picked by role, with nothing asking
whether the chosen node could be reached - where the shipped selector asks it in its
signature, as allowDisconnected: false. So a slot whose owner had gone away kept being
chosen.

"Known down", not "not connected": this core dials lazily, so an endpoint nobody has
needed yet is unconnected and perfectly usable, and barring it would route every first
command away from the node that owns the slot. An endpoint counts as down only once it has
been dialled AND has a connect fault to show for it - which is the distinction
ServerEndPoint.PublishSelectable already relies on from the other side when it excludes
DidNotRespond.

Either socket counts. Asking only about the ordinary executor missed the case that matters:
a node this core has only ever subscribed on has no ordinary executor, which is exactly the
shape of a sharded channel's node going away.

And Settle now records NOTHING when a redirect points at a node known to be down. The
re-resolution reads the slot map, and a -MOVED updates that map whether or not the resend it
caused ever landed, so a redirect to a departed node was recorded as the subscription's home
- the one answer that is certainly wrong, since it is neither where we aimed nor anywhere
reachable. Unplaced is the truth and is what lets the next ensure retry.
RetirementUnderMaintenanceTests guards against vacuity by requiring the refusing node to be
accumulating our own probe traffic. Under the engine flag that state does not arise: this
core will not aim anything at a node it has dialled and found unreachable, so the node is
simply idle rather than busy-with-probes-but-idle-of-caller-work.

Verified before changing it by relaxing only that precondition - the whole rest of the test
passes, so retirement itself was never in question. The flagged path therefore asserts that
nothing is queued there at all, which is a property worth pinning, rather than skipping;
everything after that point still has to hold either way.

Design notes 9i records the three parts of the routing fix and why the first two were each
insufficient alone.
…oken

Three consecutive full runs of the same build: shipped fully green, flagged five to seven
failures with only ReconnectRetryPolicyUnitTests in common. The rest rotates almost
entirely, and most of it is timing - intervals, timeouts, connect failures, a profiled
redirect.

Naming it as its own problem because it is one: the stable count is two and the observed
count is five to seven, so quoting either alone would misrepresent it. The likely driver is
three sockets per node under RESP2 while both cores exist, which makes it the interactive
bridge's removal to fix rather than any individual test.
The wedge in 9b-xiii was seen a third time, and this time it produced no failure list at
all: a full flagged run exceeded its thirty-minute timeout and was killed, where runs of
the same build either side finished in about three minutes. Worth recording because CI
would report that as a timeout rather than as this.

Also rules out the obvious suspicion about 9j's rotation: these runs follow hours of
cluster tests that migrate slots without migrating them back, so the shared docker cluster
was checked directly - every node reports cluster_state:ok with all 16384 slots assigned
and ok. The topology is intact, so the rotation belongs to the client.
…what is missing

An earlier note recorded the gate as measured closed. Re-measuring - gating the interactive
handshake's AutoConfigureAsync on !NewCoreEngine and running the full flagged suite - gives
26 failures, not baseline.

The failures cluster by belief, which is the useful part: the new core's handshake does not
publish the product variant and version, the tie-breaker, CLUSTER NODES as distinct from
CLUSTER SLOTS, or the SET replica probe used when HELLO cannot report the role. Each is
named in design notes 9k with the tests that catch it, and with the order to close them in.

The handshake already asks INFO server; it just discards everything except redis_version,
so the product variant is the cheapest of the four and the largest cluster of failures.
DiscoverProductAsync reads INFO server and derives the product and its version, reusing
AutoConfigureInfoField and its generated TryParse so the field names keep one spelling. It
is not a cosmetic label: ServerEndPoint decides whether a cluster supports multiple
databases from it, so a client that never learns the product applies the wrong rule.

From INFO rather than HELLO, matching the shipped core: HELLO's server field would be free
but names the product without its product version, and the version is half the belief. The
variant is decided across the whole reply, because redis_version and valkey_version can both
appear in either order.

Placed in DiscoverServerConfigAsync, already the interactive-only home for beliefs nothing
else has described, so the handshake transcript is untouched - several tests assert it
exactly. Gated on an empty recorded product version, the same "has anyone said?" test the
database count uses.

With the shipped auto-configure gated off this closes ProductVariantUnitTests (7 of 26).
Design notes 9l records the rest, including why closing the other three gaps individually
will not close the gate: the next failure is not a missing belief but a missing connection.
…erdict

Wired the whole coupled step as an experiment - shipped core stops dialling, this core
dials at connect via ConnectMode.Discover, IsConnected and IsSelectable answer from this
core, shipped auto-configure gated off - and measured it: a 311-test family went from ~20s
to 6m30s with 36 failures, spaced ~15s apart.

The 15s is ConnectImplAsync deciding the multiplexer never connected:
ReconfigureAsync(first: true) returns false and connect throws UnableToConnect after its
retries. Every failure in the list is that one exception, so nothing else about the coupled
move is measurable yet.

One hypothesis eliminated and deliberately not landed: completing OnConnectedAsync's pending
connection monitors from this core's establish, which is the obvious candidate and changed
the measurement not at all - same failures, same duration, same spacing. Dropped rather than
committed, because landing a plausible-but-unverified change is exactly how the "gate
measured closed" error in 9k happened.

Experiment patch kept in the scratchpad; design notes 9m records it.
ReconfigureAsync proves an endpoint is reachable by sending a tracer down the "already
connected, show me" path of OnConnectedAsync, and WriteDirectAsync puts that on the shipped
bridge - so under the engine flag it proves the wrong connection, and against the step that
stops that bridge dialling it is written to a connection that will never carry it and never
completes. ConnectFailTimeoutTests.NoticesConnectFail is exactly the test that catches it.

Declines unless the other core actually has the endpoint connected, so it cannot report
"unreachable" about a server the bridge is talking to perfectly well.

Measured clean in both default configurations: shipped fully green, flagged within its usual
rotation band.

Design notes 9n records this alongside the two hypotheses that looked just as good and fixed
nothing - completing the pending connection monitors from this core, and dialling this core
before the connect verdict - both dropped rather than committed. The ~15s per connect that
the coupled move still costs remains unexplained, and the next step is to read the connect
log rather than reason about it.
…ath is 82% of the rest

"How much still travels as a Message" had been answered by counting source sites, which
flatters the position badly - RedisDatabase.cs holds 395 of them and under both flags not one
runs. Measured instead, with a counter on the Message constructor over the same 2,282 tests:
79,114 messages shipped against 9,772 under both flags, and zero data commands in the
flagged run. No SET, HSET, LPUSH or ZADD at all.

Of the 9,772: the connect path is 8,050 (82%), the tie-breaker GET 862, SENTINEL 432,
SUBSCRIBE/PSUBSCRIBE 403 (built to route, never sent), SCAN 25.

So there is one structural problem left between here and a single core - the connect path,
which is where 9m/9n is stuck - rather than a long tail. Counter reverted rather than kept;
design notes 9o says how to re-run it.
Reading the connect log, as 9n said to, named it immediately: the pending connection monitor
is handed out by OnConnectedAsync and never completed, so every endpoint runs out three
ConnectTimeouts.

9n's first hypothesis had the mechanism right. It failed because it and the second hypothesis
were two halves of one change and I tested them separately - the hook had no connection to
fire from, the ordering had nobody to complete the waiter. Together they still failed, and
that exposed the real finding: muxerEndpoints=0. The multiplexer has no endpoints until
ReconfigureAsync resolves them, so any dial before it iterates an empty list and does
nothing. The dial belongs inside that method's per-endpoint loop, beside ActivateServer.

With that, connect goes from 15,081ms to 72ms, the log shows the monitor completed by this
core, and the shipped interactive bridge never connects at all. The 311-test family goes from
36 failures in 6m30s to 24 in 50s, and not one remaining failure is a connect failure - they
are the belief gaps already itemised in 9k, with the tie-breaker the largest.

Not landed: the enabling dial is not inert by default - it would add a socket per endpoint
while the shipped bridges still dial - so it lands with the switch that stops them. Patch
kept in the scratchpad; design notes 9p.
The handshake took the server type from HELLO's mode field and from CLUSTER INFO, which
leaves two cases unanswered: a server with no HELLO - exactly the case that needs another
source - and a product that spells the field server_mode rather than redis_mode.
ValkeyUnitTests is built on both at once and was read as Standalone.

Read in the same INFO server pass that already derives the product, using
ServerTypeMetadata.TryParse and accepting both spellings, which is the shipped processor's
rule rather than a guess. Guarded the way Publish guards it: a sentinel or a proxy is not
something an INFO section gets to overrule.

Against the coupled move this takes the 311-test family from 24 failures to 18, clearing all
four ClusterTopologyUnitTests and both ValkeyUnitTests - so those were never the CLUSTER NODES
gap they had been attributed to in 9k/9p, they were this. Measured clean in both default
configurations.
…nd allocate less per fill

The eviction queue grew without bound once slabs arrived (1bd841e): every
store enqueued its key, but with slabs a byte budget is met by evicting whole
slabs, and the queue is otherwise drained only for MaxEntries. Now enqueued
only when entry-at-a-time eviction will read it - a reply with no slab, or an
entry limit.

Every miss registers an InFlight so a concurrent caller can share its round
trip, and each allocated a TaskCompletionSource and its Task eagerly; on a
miss-heavy workload a second caller almost never comes. Created on demand
now, with a full-fence publish/wait handshake so a waiter arriving as the fill
lands cannot be missed. Slab key lists are presized for ~1 KiB replies rather
than regrown and recopied ~10 times per slab.

cache-scaling miss also takes a nocache switch (the baseline) and reports GC
pause as a share of wall time. All-miss, 64 async callers: 275-287k -> 289-315k
ops/s, CPU/op 33 -> 31-32us. The baseline is 533-545k at ~21us, and the gap is
GC - 28% of wall time paused, vs 1% uncached: an entry's small objects are
mid-life on an all-miss workload, and slabs churn the large-object heap. Not
admitting one-off misses at all is the lever for that.
…epeated miss

Storing a reply copies it and keeps an entry until it is evicted, and on an
all-miss workload those entries are mid-life objects: the cache's overhead
there was mostly GC (28% of wall time paused). CacheAdmission.OnRepeatedMiss
puts TinyLFU's doorkeeper in front of the fill: a first miss is remembered in a
two-probe Bloom filter over the request's hash (no allocation, no key copy)
and served without touching the cache; only a request that misses again
before the filter is cleared is stored. False positives admit a request one
miss early; there are no false negatives, so a hot request is never kept out.
The filter is ~8 bits per entry the cache can hold (4 Ki-16 Mi bits) and is
cleared after an eighth as many first sightings as it has bits.

Selectable, default unchanged (OnFirstMiss): the hit-rate side - one extra miss
before a hot key is cached, against one-off reads no longer evicting it - wants
a skewed mixed workload to judge. New counter: RefusedNotAdmitted.

All-miss, 64 async callers (cache-scaling miss ... admit):
  OnFirstMiss       307k ops/s   30.9us CPU/op   29% GC pause
  OnRepeatedMiss    592-594k     19.2-19.4us     3.8%
  no cache at all   536k         20.6us          1.2%
…ounds small replies

Since slabs, an entry was charged only its share of slab bytes - a few bytes
for a count or a flag - while its bookkeeping (the entry, the payload wrapper,
the dependency array, the dictionary node, the copy of the request) is ~350
bytes. So a byte budget never bound a workload of small replies: a million
cached counts reported ~5 MB while holding hundreds. Every entry is now charged
EntryOverheadBytes (256) plus its request's length. The remarks that still
described per-reply ArrayPool rounding are rewritten for slabs.

With the bookkeeping counted, evicting single entries always frees bytes, so
the entry-at-a-time loop now acts on a byte excess too - but only for the
thread that did the slab eviction: one that found another already evicting
slabs leaves the bytes to it, rather than racing it down below the budget.

Also parks the builder-inlining draft as design/parked/builder-inlining.patch
(was only in a session scratchpad); the ledger tracks it with the other cache
ideas still to explore.
The stored copy of the request was an array of its own per entry. It now goes
into the slab with the reply, key then reply in one contiguous range - one
object fewer per entry, and for a small reply (a count, a flag) the two sit
together: a lookup that finds the key is already beside what it came for.
RespRequest.CopyForCacheKey gains an overload that copies into caller-given
storage; requests too large for a slab keep an array of their own.

Same-session A/B, real multiplexer, hits: 100k keys at 24 threads 76.2/71.3 ->
81.4/81.8 M/s; 1 thread 6.6 -> 6.8; one hot key unchanged. All-miss: neutral
within noise - one object fewer per entry is lost in the GC cost that remains
there, which admission (a732552) is the answer to.
An entry depends on the keys its request names - a (key-table node, generation)
pair per key - and held them as an array even for the single-key commands that
are most of them. Every hit asks IsValid, so the array was one more dependent
load between the entry and the generation it compares, and one more mid-life
object per entry. One dependency is now a field; only several get an array.
The fill's own array is no longer referenced by the entry, so it dies young.

Same-session A/B, real multiplexer: hits at 100k keys / 24 threads 83.7/79.9 ->
85.7/84.0 M/s, one hot key at 24 threads 143-148 -> 150; single-threaded
unchanged. All-miss (one run each): 316k -> 342k ops/s, GC pause 24.8% -> 19%.
The miss harness's keys and cache, but each read picks its key from a Zipf
distribution (s = 0.99, a seeded table of 4M draws, so every arm sees the same
sequence), and every mode now reports the hit rate over the measured window.
Where admission's two effects meet: one extra miss before a hot key is cached,
against the long tail no longer evicting it. 64 async callers:
  OnFirstMiss     1.05-1.07M ops/s  9.8us CPU/op  72.4% hits
  OnRepeatedMiss  1.58-1.61M        7.1-7.2us     76.6% hits
  no cache        553k              19.6us
…orrected

It can write - it caches the computed cardinality back into the HyperLogLog's
header - but that is the data structure's bookkeeping, not the command's
meaning: re-running it gives the same answer, and a cache hit that skips it
skips nothing observable. Refreshing the header signals the key modified, so
the PFCOUNT that refreshes it invalidates its own cached reply and the next
one is cached: one extra miss, never a wrong answer. The code already
classified it this way; the design notes (6.9) said caching it was wrong.
CommandCategoryTests.PfCountIsARead pins the decision.
Measured on a skewed workload (Zipf, s = 0.99) it raised the hit rate as well as
throughput (72.4% -> 76.6%, 1.06M -> 1.6M ops/s), and on an all-miss workload it
removed the cache's overhead entirely; it loses only where every request is read
exactly twice. So it becomes the default - as CacheAdmission.Default, resolving
to OnRepeatedMiss, on the CacheTrackingMode pattern: zero means "the library's
choice", so that choice can move without overriding anyone who named a rule.
OnFirstMiss and OnRepeatedMiss renumber to 1 and 2 (unshipped).

The doorkeeper is now sized from the most entries the budget could hold
(MaxBytes / EntryOverheadBytes) rather than assuming ~1 KiB per entry, which gave
small replies a window several times too short.

The cache-mechanics tests (invalidation, eviction, budgets, sweeps - not
admission) now name OnFirstMiss explicitly, since they fill and then expect a
hit. Docs: the quick start shows a reply cached on its second read; the
Admission section explains the rule, the measurement, the cost and how to opt
out; "Is it working?" warns that a check reading twice is testing OnFirstMiss.
…stops routing

Every enumerator - SSCAN/HSCAN/ZSCAN, the emulated key SCAN, vector-set range
paging - checked the WithCancellation token between pages but sent each page
with no token, from when every executor refused a cancellable one. So
cancelling took effect only at the next page boundary: a slow page ran to
completion after the caller had given up, and against a server that never
answered, the enumeration never ended. RespScan.ForSend now hands the token to
the send wherever the executor can act on it; elsewhere the between-pages check
still stops it. CancellingMidPageAbandonsThePageInFlight pins it (and fails,
timing out, without the change).

RespMultiplexerExecutor.CanCancel answered by resolving a route - server
selection and the endpoint's lock - on every call made with a cancellable
token, the same shape as the Transactional regression fixed in c537026; its
routes all reach executors that can cancel, so it is now constant. That also
stops a cancellable call to a multiplexer with no reachable endpoint being
refused as "not supported", rather than failing for the reason it would.
… as a repeat

RespFest's skewed cells showed the default admission behaving exactly as
OnFirstMiss (Zipf 72.1% vs 72.5%; all-miss rows identical). The cause was the
byte hash behind RespRequest, RedisKey, RedisChannel and RespKeyTable: a 32-bit
multiply-by-33 over words folded in half, which gave 200,000 framed requests for
unpadded keys (GET rf:cbig:{i}) ~42,000 hashes between them. The doorkeeper then
took its probe from the low bits of a bare multiply, so those landed on ~4,500
bits of a million: the filter was full from the start and admitted every miss.
The local harness used zero-padded keys, which happened to hash well.

Now a word at a time with one 64-bit multiply each, then MurmurHash3's
finalizer: ideal distribution over a dozen key shapes, as keys and as requests,
at the same cost on a short request. The doorkeeper also runs its hash through a
full avalanche before probing, so a weak hash cannot collapse it again.

On RespFest's key names, OnRepeatedMiss: Zipf 72.4% -> 75.1% hits; all-miss
324k -> 610k ops/s, GC pause 22% -> 2%. Hit path: the stub single-key loop
measures 70-97ns across builds of any change to this function (one returning
the old value measured the same), so layout; 100k keys +4-7ns.

The hit-scaling harness names OnFirstMiss (it measures hits, not admission) and
uses unpadded keys, as RespFest does.
Counting only what it recorded, a keyspace smaller than the window (131,072 at
32 MiB) was never forgotten: once each key had missed once, nothing new arrived
to trigger a clear, every later miss read as a repeat, and the default admission
became OnFirstMiss for good. RespFest's Zipf cells replay a 1M-draw table of
~120,000 distinct keys, so showed exactly that: 72% either way at 64 callers.

Modelled (design/admitsim.py): that stream 72.5% -> 76.1%, independent draws
75.3% -> 76.1%. Measured with the harness, which now draws as it goes rather
than replaying its own 4M table: default 76.4%/76.0% at 1/64 callers vs 72.4%/
72.5% OnFirstMiss; 1.45M vs 1.07M ops/s at 64. All-miss unchanged (596k ops/s).
…rejected; three stale descriptions corrected
…or, dedicated pub/sub under RESP3, connection-attempt events
…ct for dedicated); the cache is opt-in, not on by default
… cannot serve a replaced value

A connection group resolves the active member's cache per command, so a write made while B was active
stamped only B's cache. A's server hears of it only by replication, and A's tracking connection is told
only then - so failing back could answer from A's entry with the value the caller had just replaced, for up
to the replication lag: read-your-own-writes broken by a switch.

Each member's cache now holds its peers (RespClientCache.SetPeers, relinked by the group on construction,
add and remove), and a local write stamps them all. A then misses and asks its server, which may still be
behind - that is replication, and an uncached read sees the same; the cache is never staler than the member
it answers for. Server invalidations and flushes stay per member. Flushing on every switch was rejected:
with equal weights a group selects by latency, so the active member can change on any health pass.

The ledger's second worry - a silent inactive member - needs no code: group members force
HeartbeatConsistencyChecks, so every endpoint is pinged each heartbeat, and a half-open tracking connection
trips the dead-socket rule and flushes that member's cache, exactly as for an active one.

Tests: GroupCacheTests (in-process servers that never replicate; both write paths, the IDatabase forwarding
and the context; both fail without the fix) and the peer relinking on Remove. ActiveActiveCacheScenarioTests
in the fault-injector tier, against real replication - compiled, not yet run.
…ts async twin does

Found porting NRedisStack, whose command factories hand back frames: SendAsync<T>(ref frame) took an optional
handler (the inbuilt one for T when omitted), while Send<T>(ref frame) demanded a non-null handler and a token.
The synchronous overload now takes both as optional, matching the interpolated Send<T> - flags stay required,
as they are there. Unshipped API, so no compatibility cost.
…gRPC's call token - in Basics.md and AsyncTimeouts.md
…ce 2026-10-09); the PR-body-only backlog items (cache metrics, AppendFormatted, .NET 11) recorded here

This branch was successfully deployed

1 active (outdated) deployment
release — 2b05b06b Deployed Oct 12, 2026 by mgravell via publish #18
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