Skip to content

V4 core spike: RESP write path, client-side cache, and the context surface - #3219

Draft
mgravell wants to merge 301 commits into
mainfrom
marc/interpolated-writer-design
Draft

mgravell wants to merge 301 commits into
mainfrom
marc/interpolated-writer-design

Conversation

@mgravell

@mgravell mgravell commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Draft / spike. Public but gated behind SER010, so nothing here is a commitment. Design notes —
what was measured, what was only reasoned about, and a decision log of what was tried and rejected —
are in design/interpolated-resp-writer.md; the working list is …-writer.queue.md.

This started as "can a custom interpolated string handler write RESP?" and is now three halves that are
one design
: a write path, the client-side cache that sits on it, and the command surface that reaches
both. It is no longer hypothetical — rendered frames go through the real pipeline to a real server, the
library negotiates CLIENT TRACKING itself, and real invalidations arrive.

Framing: this is the V4 core, not a parallel option. We cannot run two write models through one
backlog, so the old Message machinery is a deletion rather than a reconciliation — but IDatabase
stays, served by the new core. Message itself stays too: it is abstract over exactly two members
(ArgCount, WriteImpl), so the core is already pluggable at precisely the rendering step; what goes is
the population of 36 subclasses that exist to write one command each.

Why the halves are inseparable

The cache depends on facts that only exist during the write:

  • The rendered frame is the cache key — the bytes that were going to be sent anyway, so a lookup
    costs a render and no allocation.
  • Which arguments were keys is unrecoverable from the bytes. A frame is N bulk strings; key-ness is
    writer-side semantics, and invalidation needs it. The writer records it as it writes. This is the
    load-bearing fact.
  • Canonicality becomes correctness. Identity comes off the bytes, so two logically identical commands
    must render byte-identically or the cache silently stores duplicates.

The write path

await ctx.SendAsync<bool>($"SET {key} {value}", flags);   // *3\r\n$3\r\nSET\r\n$6\r\nuser:1\r\n$4\r\nmarc\r\n

At no point is a string built — the compiler lowers each hole into AppendFormatted, writing UTF-8
straight into a pooled buffer. $"SET {key} {value}" renders identically; whitespace contributes nothing.

Optional arguments are holes, not branches. Expiration.Default and ValueCondition.Always
contribute no argument, so all of SET is one interpolation:

=> ctx.SendAsync<bool>($"{RedisCommand.SET}{key}{value}{when}{expiry}", flags);

That replaces a ~17-branch decision tree in RedisDatabase.GetStringSetMessage, most of whose branches
pick between fixed-arity Message.Create overloads. Arity is free here: *N is accumulated while writing
and back-filled into a reserved prologue.

One pass also folds the cluster slot (MULTI when keys disagree), the key marks, and key and
channel prefixes
. Append exists for a genuine caller-side branch; the append handler is the command
handler, moved in and back out, so both accept the same things by construction.

Fixed tokens (EX, NX, container subcommands) are pre-framed RespFragments carrying an
ArgCount, since multi-token is the common case (~140 call sites). Hand-construction is gated behind
SER011 — deliberately not in NoWarn — because malformed bytes desync every subsequent command.

Extending the vocabulary: extension AppendFormatted methods do not bind in a hole (verified on the
current compiler), so the vocabulary would be closed and closed to us. IRespArgument /
IRespFormattableArgument are the way back in — an interface on the argument, deliberately unrelated so
implementing only the formattable one makes the format mandatory. Struct implementers are constrained
calls, so nothing boxes, and an implementer cannot miscount because it writes through the handler's
own counters. There is no alignment overload and must never be one: $"{key,10}" would pad the payload
and send a different key.

The client-side cache

Two independent lookups rather than a cross-index: table 1 keyed by frame + database, table 2 by
key bytes, no database. A server invalidation touches only table 2 — one hash, one stamp, never an
enumeration. Measured: ~5-6ns, zero allocation, flat from 1 to 100,000 cached keys. The database
asymmetry is protocol-faithful: Redis tracking uses a single key namespace.

The race that produces permanent staleness. if (!TryGet) { Send; Add; } loses an invalidation
arriving during the send — and the server never repeats it, so the entry is stale permanently.
Generations are captured before the send, which is why the cache is a participant in the send, not
the entry point
. A cache hit completes synchronously and allocates nothing. Both sides are pooled and
ref-counted, not spans: a span cannot cross an await or sit in a backlog awaiting resend.

Everything fails closed: keyless requests are refused (invalidation only ever reports keys, so they
would be permanently stale); an undeclared retry category is refused (zero sorts below read-only, so a
naive <= reads "nobody said" as "safe"); NoClientCache suppresses the probe as well as the store; and
with prefixes configured, any key outside them is refused, because otherwise there is no invalidation path.

Bounded: MaxBytes/MaxEntries with sampled eviction, MaxPayloadBytes, periodic sweep, and
counters (Stored, RefusedRaced, RefusedNotTracked, …) because the failure mode this design can still
produce is silent and durable.

Config splits in two: CacheOptions is global per multiplexer (prefixes, tracking mode, quotas);
CachePolicy is per-context (TTL, grace). RespClientCache itself is internal — only the multiplexer
can mint one, because a cache the caller attached by hand has no CLIENT TRACKING behind it and would
fill and never invalidate. The public opt-out is ctx.WithoutCache().

End to end: ServerEndPoint now issues CLIENT TRACKING ON [BCAST] [PREFIX …] during the handshake,
built from CacheOptions, and refuses loudly without RESP3 rather than caching without invalidation.
PhysicalConnection routes the invalidate push. The in-process server models tracking too, so the tests
exercise the library's negotiation rather than their own.

The command surface

await db.Strings.SetAsync("user:1", "marc");
var name = await db.Strings.GetAsync("user:1");

Eleven groups so far (Strings, Hashes, Sets, SortedSets, Lists, Bitmaps, Keys, Scripts,
HyperLogLog, Geospatial, VectorSets), one file each. Three reasons: discoverability (IDatabase is a flat wall of several hundred methods);
module libraries become first class (ctx.Search.Query(...) composes exactly like ctx.Strings.Set,
inheriting cacheability and prefixes rather than needing a parallel story); and it is the last break
after a context exists, every addition is an extension member.

The Async suffix stays even though only the async API exists. The earlier spelling dropped it on the
grounds that there is nothing to disambiguate from; that was wrong for the ecosystem it lands in — analyzers,
ConfigureAwait guidance and readers all key off the name, and db.Strings.Get(key) returning something you
must await reads as a bug at a glance.

Commands are ordinary this in extension methods, not extension blocks, and that is about retirement:
deleting the this un-binds new call sites while compiled callers keep working. Only the group accessors
are blocks, since an extension property has no other spelling.

Which leaves down-level consumers, since an extension property needs C# 14. Opt into
StackExchange.Redis.Interpolated.Downlevel and the same groups appear as methods — db.Strings().GetAsync(key)
— with nothing else moving. Measured on real toolchains rather than by pinning LangVersion (which is stricter
than reality, and was the first round's mistake): Mono/net472 at C# 7.3, the .NET 6 SDK at C# 10 and the
.NET 8 SDK at C# 12 all compile that with the property in scope at the same time, because extension-block
metadata is invisible to a compiler that predates it rather than merely unusable. So the property costs those
consumers nothing, and only C# 14 callers must leave Downlevel alone (importing both is CS9339, a compile
error, never a silent misbind). Written by hand — a generator would tax every consumer build to save one line
per group — and held to coverage by a test rather than a build step.

Targets are split by what they may offer. IRespKeyspaceTarget (database, batch, transaction) carries
the keyspace groups; IRespServerTarget is for server-scoped ones. Previously every group bound to
IRespTarget, which IRedis carried — so server.Strings.Get(key) compiled, on a type pinned to one
endpoint. Note what did not need splitting: the executors. A batch's context already queues and a
server's already pins, because the executor's target is the batch/server and ExecuteAsync is overridden
on both.

No arrays on the new surfaceReadOnlyLease<T> and friends instead, with a parallel internal
extension where the old API still needs an array. Multi-value replies go further and become windows into the
reply buffer
: MGET of a thousand 32-byte values allocated 56,656 bytes before, 736 after. The array was
never the expensive part — a RedisValue has no lifetime, so it must own its bytes.

The context composes in every direction, and never assigns. Services compose (the multiplexer attaches a
cache, a caller adds a probe, neither erases the other); key prefixes compose, matching what WithKeyPrefix on
IDatabase already does by folding the two prefixes into one decorator; and channel prefixes now do too. That
last one was assigning, which is the bug this shape exists to prevent: a context is handed down through code
that does not know what its caller applied, so a library reaching for its own channel namespace could silently
cancel the tenant isolation above it — and nothing looks wrong afterwards, because the frame is well-formed and
goes to the wrong channel. There is deliberately no reset, exactly as you cannot un-prefix a RedisKey or
unwrap a decorator.

So the prefixes are spelt AppendKeyPrefix/AppendChannelPrefix, not With*. With reads as "the result
differs in this respect", which invites "so the second call wins" — the exact misreading that had just been a
live bug. Append rather than Prepend because the new prefix lands nearest the key:
AppendKeyPrefix("a").AppendKeyPrefix("b") sends k as abk. Everything else keeps With*, which is now a
real distinction: WithDatabase/WithServerType replace a value, WithCache/WithScriptCache rebind a
capability by shadowing, and only the prefixes accumulate. The shipped DatabaseExtensions.WithKeyPrefix on
IDatabase keeps its name — it behaves the same way, and renaming it would be a source and binary break to
fix a name.

Cancellation is per-call, and refused honestly. It was going to be context state (WithCancellation(token)),
which is wrong twice: a token's lifetime is the operation's, not the connection's, and a context is a value
that gets captured and reused. So it is a CancellationToken argument on Execute/ExecuteAsync — and since
the pipeline cannot yet cancel an in-flight request, a real token throws NotImplementedException rather than
being silently ignored. An already-cancelled token is honoured properly, because that one can be: the command
is recycled and ThrowIfCancellationRequested follows, so an obvious no-op does not leak a pooled buffer.

Scripts: composition, not a special frame

EVALSHA is always issued, with SCRIPT LOAD composed in front of it when needed. The alternative — a
frame that re-spells itself as EVAL <body> after a NOSCRIPT — would have been the first exception to
a frame is a pure function of its arguments, which is what makes frames cacheable, routable and
blit-writable. So the cleverness moves to composition instead, and the frame abstraction is untouched.

A registry renders each script's SCRIPT LOAD once (exact-sized array, pooled rent returned). At write
time
a gate asks whether the endpoint is already believed to hold it and skips the preamble — write
time, because the endpoint is not chosen until then, and a resend after NOSCRIPT, a reconnect or a
MOVED must re-decide. The belief is the endpoint's existing one, shared with the classic path.

IMultiMessage.GetMessages can now return null for "nothing to compose, write me normally", so the
common case does not build an enumerator to carry a single element.

Transition

RespFrameWriter renders real Message objects into RespFrames, so the existing surface feeds the new
pipeline without being rewritten; both routes render byte-identically (pinned, because that is a cache
correctness property). The integration is one hookMessageWriter.Write(in RedisKey) reports the
offset, the one fact the bytes cannot carry — A/B measured at 66.96ns with vs 68.77ns without on
SET key value, i.e. below the noise floor.

TransitionalDatabase implements IDatabase over the new surface; SER352 reports the members still
unimplemented (currently 186) so the gap is a build warning rather than a surprise.

Status

Full dotnet build Build.csproj -c Release clean on all six target frameworks; complete suite green
(7514 passing). Frames are validated by parsing them back with RESPite's RespReader and
DemandEnd(), so over- and under-run both fail. Works on net461/netstandard2.0 — handlers are
compiler lowering, and a source polyfill covers the attributes.

Measured, not argued: the zero-allocation cache hit; OnInvalidate cost and flatness; the
MessageWriter hook A/B; the MGET allocation collapse; invalidation timing against a real server
(self-invalidation always trails its reply, which makes local-write eviction a correctness requirement rather
than an optimisation); and what a transaction condition costs a thread — with a 250ms round trip injected,
ExecuteAsync blocks the calling thread for 508ms with one condition versus 2ms with none, i.e. two round
trips done synchronously before you are handed a task to await. That is thread-pool starvation shaped, and it
is the number the WATCH work has to beat.
Not measured: the interpolated writer against the existing path end to end — that argument is still
architectural.

Since this was opened

Done: CLIENT TRACKING end to end; the IServer context; the keyspace/server target split; three more command
groups; SetResult split into inspect-vs-parse (6 overrides vs 89), which moved NOSCRIPT retry into the
pipeline and deleted eight catch sites — and fixed a real bug on the way, since the frame path never noticed
-NOSCRIPT and so could hold a stale script belief permanently; IRespHandler.Parse(ref RespReader) taking
the positioned reader rather than a span; the Async suffixes, per-call cancellation and the down-level shims
above; and errors stay exceptions — the redis.call vs redis.pcall question — because an errors-as-values
opt-in is a post-verdict decision that the inspect/parse split has now made cheap, rather than a pervasive
second API.

Also done since: one projection per element type, shared by every aggregate form — seven element types had
the same projection written twice, up to 250 lines apart, and long? had it three times. No divergence had
happened, but nothing prevented one, and a pair that disagreed would be invisible: both forms succeed and
return the length you expected.

HIMPORT probed, and it is not a third seam. GetMessages is called from WriteMessageInsideLock with
the PhysicalConnection in hand, which is exactly the "inject once the connection is known" the bridge's
hard-coded HIMPORT injection has — so FramePairMessage + IRespPreambleGate covers it with no new
mechanism, and the only difference from EVALSHA is the gate's scope: connection rather than endpoint.
What the probe did find is a second axis the interface does not name, when the belief is recorded. A script
confirms on the reply, because only the reply proves the server has it; a field-set cannot afford that, since
every import issued before the first PREPARE returns still reads "not prepared". Measured under a contended
burst of 8: confirm-on-reply injects 8 preambles, claim-on-write injects 1. Both are correct — PREPARE
is idempotent — so it is a cost difference invisible without counting. Two of the three probes are now done
and both landed on the same seam, so the count is two mechanisms rather than three.

Batches and transactions now offer the groups by name. IDatabaseAsync carries IRespKeyspaceTarget, so
tran.Strings.SetAsync(...) binds directly. That is a required-member break for anyone implementing
IDatabaseAsync/IBatch/ITransaction, taken deliberately: extending this family has always been the only
way to add functionality here, which is the problem this work exists to end — and after this one, every
addition is an extension member. The composed pair refuses inside a transaction rather than shifting EXEC
positions, and by structure rather than a sixth hand-written guard: QueuedMessage default-refuses anything
whose CanWriteWithoutExpansion is false. That refusal is worth more than it looks — flipping the flag does
not fail the test, it hangs it, because the pair's result box is on a message that is then written but
never enqueued for a reply.

Replies that are windows, not arrays. RespReply is a disposable base for replies whose contents point
into the buffer they arrived in; Streams.RangeAsync is the first command on it, and the deferred shape is a
deliberate trade rather than a free win. Measured against materialising the same thousand-entry read: ~5%
slower for ~2,100x less memory
, with equal work on both sides. (An earlier pairing here claimed "18% faster";
it compared traversal against materialisation and was not a fair test.) RespReply allows external
construction, so NRedisStack-shaped consumers can derive rather than wrap.

RespKey: a borrowed key. For the ad-hoc/IRedisKey question from #2578 and #2844 - a key that can be a
span, a ReadOnlyMemory<byte>, a string or null, with no allocation. Null is a third state rather than an
empty payload, which also fixed something nobody had noticed: default(RespKey) now matches
default(RedisKey) in being null. $"{someString}" does not get key handling, by the way - it binds to the
RedisValue overload, so no prefix, no mark, no slot. That is why the key spelling is explicit.

Cancellation reaches the surface. All 272 group methods take a CancellationToken, threaded to the send.
Free now, a binary break after SER010 comes off - which is the deadline that matters, not "later".

A group is now its own files. Thirteen groups moved from RespSurface.<Group>.cs partials of one class to
Groups/<Group>.cs (group type + accessor) + .Methods.cs (+ .Types.cs where the group owns types).
SORT has no group, so it is Keys.Sort.cs. The accessor cannot live on the group class - CS0542 - so each
group contributes its own partial to RespDatabaseExtensions, which means adding a group is one file and no
central list can drift. The RS0026 suppression moves with it and becomes a per-group claim that can actually
be checked.

Command text that carries a decision is written once. The lease/array/writable-lease siblings meant most
commands were composed two or three times; where that text holds an optional token, a derived operand or a
command chosen from an argument, the copies can disagree, and disagreeing changes the reply's shape.
Factored across seven groups - including the whole FIELDS numfields field [field ...] family in hashes,
eighteen sites behind one factory with the count derived from the span. Bare texts stay where they are sent:
there is nothing in $"{RedisCommand.GET}{key}" to get out of step, and hiding it behind a factory costs the
legibility this surface exists for.

A silent wrong answer, found and fixed: WithDatabase did not change database. The database is not part of
a rendered frame - no SELECT is written - so routing is the executor's, and WithDatabase only replaced a
property: db.WithDatabase(1).Strings.GetAsync(key) read database 0 and reported 1. Worse than usually
silent, because the client-side cache keys on the executor's database too, so nothing was inconsistent with
anything; the answer was just wrong. Pinned against a real server, and the test fails without the fix. Found by
asking what a server-scoped DBSIZE would need.

Naming, and a version. RespFrame -> RespRequestFrame and RespCommandHandler -> RespRequestBuilder:
XRANGE is a command, XRANGE 1 4 COUNT 10 is a request. version.json says 4.0.

.NET 11 runtime-native async: measured, and not adopted. It needs both <Features>runtime-async=on</Features>
and an assembly-level attribute that the RC1 ref pack does not ship, so the attribute is declared locally and
the whole target is behind /p:IncludePreviewTargets=true, forced off while packing - a gate that had to be
written where a global property cannot outvote it, because the obvious spelling silently put net11.0 in the
.nupkg. On RC1 it helps the inline path ~8% and costs the suspending path ~22%, so: revisit at GA. (The
harness lied first: Task.Yield() moved continuations to the thread pool and made suspending look faster
than inline, which is impossible. Marc spotted it.)

The first server-scoped group exists: server.Server.DatabaseSizeAsync(db), on IRespServerTarget -
an interface that had been declared with nothing bound to it - reached through RespServerExtensions, the
server-side twin of RespDatabaseExtensions. Getting the name cost a rename: a public type
StackExchange.Redis.Server and a namespace StackExchange.Redis.Server are both reachable as Server from
inside StackExchange.Redis (CS0435), and the in-process test server had that namespace. Rather than call
the group Servers or split the class and accessor names, the toy moved: assembly and package still
StackExchange.Redis.Server, code now in StackExchange.Redis.ManagedServer. DBSIZE takes its database
explicitly - a server context has none of its own - and that is real routing, since the command takes no
operand and follows the SELECT the pipeline applies.

Next, in likely order: the arity-2 walker that finishes
the row-parser collapse (HashEntry, SortedSetEntry); wiring the real HashImport field-set through the
frame path so the bridge's if (cmd is RedisCommand.HIMPORT) type test can retire; more command groups;
ISubscriber's context; and dropping the .Interpolated namespace, which is free while SER010 is on.

Blocked, and honestly so: WATCH/MULTI. The write loop pauses mid-transaction to decide between
UNWATCH, EXEC and DISCARD — and the fix is not a redesign of that pause, it is the Message/write-loop
refactor that removes the pulse the pause waits on. Designing around a mechanism already scheduled for
deletion would be wasted work, so this one waits.

Structural fixes, both of which hid content:
- 6.8 and 6.9 had been appended before the '## 9' marker and so were stranded
  inside section 8 (prior art), not section 6.
- 6.5 sat after 6.6 and 6.7, which were nested under 6.4 as '####'.
All four are now siblings of 6.1-6.5, in order.

Staleness fixes:
- 6.7 still described GetOrExecute, which was removed when the cache became a
  participant in the send rather than its owner. One mention is kept,
  explicitly marked as the rejected earlier shape.
- Open questions still listed the promotion seam, which 5.2 closed.

New: a decision log (6.10) recording what was chosen, what was rejected, and
why - 16 entries, since most of that reasoning existed only in commit messages.
The reversals are the useful part, so two are called out in full: that the case
for opt-in caching rested on FT.SEARCH being keyed, which it is not, and that
5.2's 'no spare bits' was true of the frame but not of the writer.

Also recorded the non-atomic cross-key validation as a deliberate tolerance
rather than a defect - either ordering is a legitimate observation for a read
racing a write - and six new open questions from this work: command metadata
for non-deterministic commands, whether module reads register for invalidation,
that nothing turns tracking on and the invalidate push is actively dropped,
that replies are still copied rather than sharing the reply frame's lease,
running a ResultProcessor over a cached payload, and bounding both tables.

Section 9's file inventory extended with the eleven files this work added.
@mgravell mgravell changed the title Spike: write RESP commands from an interpolated string Spike: RESP write path and client-side caching — one design, not two Sep 13, 2026
InterpolatedWriterDemo still asserted '<scan>' for the three-key variadic case
and still passed - but only because its helper used a fixed two-element buffer,
so TryGetKeys returned -1 for 'target too small' rather than 'cannot report the
keys'. Same sentinel, different meaning, and the same bug already fixed in
InterpolatedWriterUnitTests.

The helper now sizes from KeyCount, so the two cases cannot be confused, and the
worked example asserts the keys the bitmap actually resolves. These examples are
documentation as much as tests, so one printing '<scan>' for something that now
resolves is actively misleading.
Request combining (HybridCache's placeholder + TCS) is real complexity, and the
economics here are not the same: a miss is a round trip on an already
multiplexed connection, not an arbitrary factory call, so a thousand concurrent
misses are a thousand pipelined commands and a thousand O(1) lookups.

Two cases still flip it, and the first is self-inflicted: dropping the whole
cache on disconnect makes every hot key re-fetch at once, precisely when the
connection has just been re-established; and large values make a redundant miss
expensive in bytes and buffers.

Cancellation matters more than I first thought. v3 adds it, so the naive form is
actively WRONG - passing the first caller's token to the shared send lets one
caller's cancellation abort everyone who joined. The shared send needs a
cache-owned token with each waiter observing its own. That makes this a
decide-alongside-cancellation question rather than a later one.

But last-man-standing is still not a correctness requirement, for a better
reason than 'the work is not cancellable': the cache is a stakeholder
independent of the callers. In HybridCache, if everyone cancels the work is
pointless. Here, completing the fill populates a cache later callers will hit,
so it has standalone value - let it complete, commit it, and let each waiter
observe its own token. Withdrawing an unsent command when the last waiter
leaves becomes an optimisation.

Also recorded: combining can hand a joiner data OLDER than its own request would
have seen, if a write lands between the leader's send and the joiner arriving.
Transient, so tolerable, but stated rather than accidental - and cheaply
mitigated by refusing to join a fill whose generations are already stamped.

RedundantFills counts fills that completed only to find the same request already
cached - exactly what combining would have collapsed. One increment on an
already-cold path, and no in-flight table, so it does not presuppose the design
it is measuring.
Cancellation: the request completes or fails by itself, including caching, and
a caller's cancellation applies only to that caller's await. No extra token, no
waiter tracking, no last-man-standing - HybridCache needs those because it
fronts external systems whose work is worthless once nobody waits; here the
fill populates a shared cache, so finishing it has value regardless.

Errors: TryComplete refused nothing before this, so a '-ERR' or '-MOVED' reply
would have been cached. The invariant that makes this cache sound is that a
reply is a function of the keys it depends on and that the server reports when
those change; an error need not be - it can come from config, topology, ACLs,
memory pressure or a module's state, none of which key invalidation covers - so
nothing would ever evict it. That turns a transient failure permanent, the same
class of bug as caching a keyless command.

-WRONGTYPE genuinely IS a function of the key and would invalidate correctly,
but telling those apart needs per-code knowledge for something that should be
rare; and if errors are not rare, caching them hides that. Hence RefusedError,
where a non-trivial count is itself the finding.

Nulls are values, not failures, in all three spellings - $-1, *-1 and RESP3 _ -
and Redis tracks keys 'mentioned in the context of a read-only command' whether
or not they exist, so creating the key invalidates the entry. Negative caching
works and works correctly. No null spelling starts with '-' or '!', so the cheap
first-byte test needs no knowledge of null forms.

Mutation-tested: pointing IsError at the wrong span fails exactly the four error
cases and nothing else.
RESP3 permits attribute metadata (|) ahead of a value, and nothing in the spec
exempts errors or nulls from carrying it. So

    |1\r\n$6\r\nttl-ms\r\n:1000\r\n-ERR something went wrong\r\n

begins with '|', passes a leading-byte test, and gets cached as though it were
data - a permanently cached error, which is the exact failure the previous
commit added the check to prevent.

No server is known to emit attributes today, which is what makes it dangerous
rather than harmless: the bug stays latent until a server, a proxy or a future
protocol revision uses a feature the protocol already allows.

Now uses RespReader.TryMoveNext(checkError: false), which skips attributes and
lands on the first content element, plus RespReader.IsError to classify it.
checkError:false matters - the default overload throws on an error, which is
the thing being detected.

A reply with no content element at all (metadata only, or empty) is refused
too: unclassifiable fails closed.

Verified by reverting to the first-byte implementation: exactly the two
attribute-hidden error cases and the no-content case fail, and nothing else.
Attributes are the ONLY construct that can precede a value, so if the first byte
is not '|' it IS the first content element's prefix - which makes the cheap test
exact rather than approximate, not a heuristic that happens to work. Protocol
parsing is reserved for the branch that needs it, which with no server emitting
attributes today is in practice never taken.

The attribute path is [MethodImpl(NoInlining)] for the same reason the
MessageWriter fallbacks are: RespReader is a sizeable ref struct, and
constructing one in a cold branch changes codegen for the whole method.

Both branches stay pinned. The tests fail against a first-byte-only
implementation (attribute-hidden errors get cached), and they fail again if the
attribute path stops classifying - so neither branch is decorative.
The if/return pair was a switch expression with extra steps; the three cases -
attribute, error, everything else - are the actual shape, and naming them as
cases makes the exhaustiveness obvious rather than implied by the ordering.
InvalidationIsAllocationFree failed once in a full run and passed in isolation
and in four other runs. The cause is mine, not the code's: an exact
GC.GetAllocatedBytesForCurrentThread window is not reliable under load, because
tiered JIT recompilation can promote a method mid-loop and allocate on the
measuring thread. A test that only flakes under load is the worst kind.

AllocationAssert takes the BEST of several windows, which keeps the assertion
exact rather than softening it into a tolerance: code that allocates per call
allocates in every window, while one-off runtime noise does not. It stops early
once a window comes back clean.

Applied to all three allocation tests - the invalidation flood, the zero-alloc
cache hit, and the Detach-vs-borrow contrast - since they all had the same
fragility and only one of them had shown it yet.

Four consecutive full runs clean.
Section 9.4. Three reasons, ordered by which audience feels them:

1. Discoverability. IDatabase is a flat surface of several hundred methods and
   IntelliSense on 'db.' is a wall; ctx.Strings/ctx.Hashes/ctx.Streams groups it
   the way Redis documents itself, so the shape of the API teaches it. The
   benefit an ordinary caller notices first.
2. Module libraries become first class. NRedisStack reaches the server today via
   db.Execute or its own parallel interfaces; extension members over a shared
   context make ctx.Search.Query compose exactly like ctx.Strings.Set - same
   cancellation, key prefix and cache participation, no wrapper interface. They
   then arrive through the same Send, so they inherit the 6.9 cacheability gates
   rather than needing a parallel opt-out story.
3. It is the last break. Adding to IDatabase has been standard practice here, so
   the cost is familiar; the difference is that this one ends the sequence. The
   guarantee rests on a discipline rather than the type system, so it is written
   down: the first 'just this once' interface method afterwards spends the break
   for nothing.

Also recorded the four things to settle first, of which one is a hard
prerequisite rather than a refinement: Detach() drops the key marks, slot and
argument count, so inside IRespExecutor.Send there is no way to ask which
arguments were keys and a cache decorator cannot begin a fill. Retry likewise
needs the flags it is not given. Both want widening RespRequest before the
decorators are built, not after.

Plus: ref readonly cannot cross an await so it buys nothing on a ValueTask-first
surface; decorator order (cache outside retry) is silent and wants a test; and
cache/retry are not an either-or - the decorator is an executor, installing it
is a With on the context.

Cross-linked from the open question on public API commitment, which argues the
opposite direction for the handler surface; the two want reconciling.

No code changes.
Detach(flags) and AsLookupKey(flags) now carry the key marks, slot, argument
count and flags; the request exposes KeyCount/TryGetKeys/GetKey/Slot/ArgCount/
Flags. Mark resolution moved to statics on RespFrame, so a frame and a detached
request answer identically rather than by duplicated logic.

This was the prerequisite for the executor-decorator model: previously Detach
dropped everything but the bytes, so inside IRespExecutor.Send there was no way
to ask which arguments were keys - a cache decorator could not begin a fill, and
a retry decorator could not tell whether resending was safe.

The division of labour is now explicit. Routing needs the SLOT and nothing else:
one int, already folded during the write, and gated on ServerType == Cluster
because CRC16 over every key is the expensive half. Only caching needs the key
marks, which are a few field writes and so are unconditional. Cheap thing
unconditional, expensive thing conditional.

The fold still covers every key rather than just the first: it is not only
producing a routing value, it detects cross-slot, which is a correctness check
in cluster. First-key-only would give a plausible slot for a command that must
be rejected.

Identity is unchanged and now pinned: Equals/GetHashCode are the rendered bytes
alone, so two callers issuing the same command with different CommandFlags share
a cache entry rather than duplicating it.

Also recorded that GetDatabase() becomes the secondary API, with
NewThing() => GetDatabase() as the interim, since IDatabase implements the new
interface - keeping the transition a rename rather than a fork.
IRespTarget (one member), RespStrings (one field), RespSurface (extension
members), a fake executor, and eight tests. Set/Get work through the cache, and
WithKeyPrefix is a context clone with no per-method forwarding.

Findings:

- Extension members compile on EVERY target, net461 and netstandard2.0
  included. They are compiler lowering, like the interpolated handler, so the
  down-level story holds. This was the main risk in the plan and it is gone.
- Each extension member costs TWO PublicAPI entries: the extension(...) form
  and the lowered static (get_Strings). So 'extension members break nobody' is
  true for source and binary compatibility but not for API tracking; worth
  knowing before the surface is hundreds of commands.
- A plain wrapper is enough. RespStrings holding one RespContext field is
  layout-identical by construction, so the wrapper IS the pun - compiler
  enforced, no Unsafe, no ref readonly, and no lifetime requirement pushed onto
  callers. Context returns by value and everything stays async-usable.

And one bug it exposed: RefusedByFlags was unreachable in real use. The
orchestration skips the cache entirely when flags forbid caching rather than
probing and declining, so TryBeginFill was never reached and never counted. A
diagnostic reading zero because nothing asks it looks like evidence, which is
worse than not having it. The decision now runs through cache.PermitsCaching so
the cache observes every refusal without probing what it was told to leave
alone.

RespContext gains Executor and Cache, threaded through every existing With*
clone - which previously would have dropped them.
Two parameters collapse into one: Send/SendAsync take 'in RespContext' instead
of a cache and a cancellation token, and since the context carries the executor
they hang off the context rather than the executor.

The cache is now a SERVICE on the context rather than a field, using
RespReader's slot pattern: one object? that either is the requested service - a
type test, the common case - or is an IServiceProvider for things the context
knows nothing about. That gives extensibility with no new fields, which matters
because the entire point of 9.4 is to stop adding members; adding one per
capability would have been a poor start. WithCache is now sugar over
WithServices. The executor stays a real field: required on every call, where a
cache is optional.

Also recorded WHY the cache is not an executor decorator, since it keeps coming
up as an attractive simplification:

- The executor contract deals in OWNED requests, because a backlog or resend may
  need the bytes past the call. A decorator would therefore receive an
  already-detached request and pay for ownership on EVERY call including hits -
  the 48 bytes AsLookupKey exists to avoid, and the zero-allocation hit with it.
  Lazy upgrade does not rescue it: a borrowed request points at the frame's
  pooled array, so minting a lease gives two owners that both pool-return it.
- It would pin the executor to returning raw bytes forever, foreclosing any
  later move to executors that return processed results.

So the frame level decides whether to form and send at all; the executor chain
operates on a formed, owned request. Retry belongs in the chain because it
resends the same bytes; caching belongs above because it decides whether bytes
are needed at all.
'FT.SEARCH'.Command() parses, validates and optionally frames a name once. What
it stores depends on whether the library knows the name, and that is a
correctness split rather than an optimisation:

- known -> the RedisCommand, deferred, because CommandMap is per-context and may
  rename OR disable it; the map already holds the bytes.
- unknown, casual -> the string, so inline use encodes straight into the frame
  buffer; preforming there would allocate an array to copy from and discard.
- unknown, preform: true -> framed byte[], so a static readonly field pays once
  and every use is a memcpy.

preform has no effect on a known command: CommandMap already stores every mapped
name as a pre-framed fragment, 'ready to throw directly into the stream', which
is the only place they can be preformed since the map is what decides them.

Preforming an unknown command is safe because CommandMap is built by walking the
RedisCommand enum, so an override keyed on a name it cannot parse is silently
ignored. Nothing could rename it. (Also a gap: module commands cannot be renamed
or disabled client-side at all, while a server-side rename-command on one works
and is undetectable - see the COMMAND findings.)

A u8 overload takes the name as bytes, so generated code and static fields need
no string; TryParseCI matches on bytes directly, so even known commands need no
transcoding.

Position decides the meaning and the bytes are identical: first it is the
command, later it is an argument naming one. That second case matters - a server
knows a renamed command ONLY by its new name, so COMMAND INFO must be passed the
mapped spelling, and taking it from the map is the only way to get that right.

Validation is at resolution, not on the wire: CR, LF or a space would
desynchronise every subsequent command, so it is rejected once where it is free.

13 tests.
AppendLiteral tokenizes on whitespace. With nothing written yet the first token
is the COMMAND - parsed, mapped through CommandMap, framed verbatim if
unrecognised; every later token is an ordinary value argument encoded straight
into the frame. Whitespace-only literals still contribute nothing.

So $"SET {key} {value}" renders byte-identically to the hole form, and
$"COMMAND INFO {name.Command()}" works.

Rejecting these was the wrong call: the form did exactly what it looked like,
and refusing to compile it bought correctness we did not need. SER309 is now a
warning saying the cost - parsed and encoded per call, where a [Resp] fragment
or a RespCommand resolves once - rather than an error.

Splitting on whitespace gets container commands right for free: CONFIG GET name
is three arguments with CONFIG mapped and GET not, which is exactly how
CommandMap behaves since it maps container verbs only. That fell out rather than
being designed.

Costs contained: AppendLiteral fast-paths empty and single-space before entering
the tokenizer, so the recommended spelling pays nothing for the readable one
existing, and the tokenizer is NoInlining for the same codegen reason as
MessageWriter's fallbacks. Nothing allocates - index arithmetic over the literal
and a pointer-based encode, since GetByteCount(ReadOnlySpan<char>) does not
exist on netstandard2.0 or net461.

A literal token is never a key: key-ness comes from the hole type, so routing
and invalidation are untouched.

Two existing tests asserted the old behaviour and now assert the new; the
analyzer tests assert Warning; and the tests that deliberately exercise the
warned form suppress SER309 locally, as the generator does for SER011.

11 new tests.
A leading literal is the command, so it gets a different fix from a token in any
other position - keyed on the same positional rule the writer applies at
runtime, so the fix and the behaviour cannot disagree.

  leading, RedisCommand reachable -> RedisCommand.SET
  leading, not reachable          -> static readonly RespCommand field
  anywhere else                   -> the existing [Resp] fragment fix

RedisCommand is internal, so this library's own code gets the enum - no parse,
and a typo becomes a compile error - while external callers get a field. The
field is declared with preform: true because it IS a static: a no-op for a
command the library knows, since the command map already holds its bytes, and a
real saving for a module command.

The code-fix harness compiles against the public surface with no
InternalsVisibleTo, so it is an external caller and RedisCommand is genuinely
out of reach there. That turns out to be the useful half to test: even SET gets
the field, which proves the fork rather than assuming it. Three tests.
'parse the name, apply the command map, throw if disabled' was written out in
four places: the string constructor, a leading literal token, RespCommand, and
the enum overload. That is exactly the kind of three-step rule that drifts when
it is duplicated - and the thing they all actually needed was the command map,
which is the context's to own.

RespContext.ResolveCommand(RedisCommand) and TryResolveCommand(ReadOnlySpan<char>)
are now the only implementations; the single remaining CommandDisabled throw is
in the context. TryResolveCommand returning false means 'not a command this
library knows', which the caller frames verbatim - and no command map can affect
that, since the map is built by walking the RedisCommand enum.

RespCommand.GetResp takes 'in RespContext' rather than a CommandMap, so it
resolves the same way as everything else rather than reaching past the context
for one field.

No behaviour change; 6378 + 174 tests green.
CS1657 means the handler cannot be held by 'using', so a throwing window between
Compose and Execute needs try/finally. That is a property of the interpolated
handler pattern rather than of this design - DefaultInterpolatedStringHandler
has the same shape and the same limitation.

Section 6.5 already leans on that precedent for abandoning the rented buffer
when an interpolation throws, so the two now cross-reference: one precedent,
both warts, stated as a decision rather than an apology.
@mgravell mgravell changed the title Spike: RESP write path and client-side caching — one design, not two Spike: RESP write path, client-side caching, and the context surface Sep 14, 2026
One interface edit - IRedis inherits IRespTarget - gives IDatabase, IServer and
ISubscriber the member, since IRedis is their shared base. The blast radius
inside the library was four types:

  RedisBase                 throws (covers RedisDatabase/Server/Subscriber)
  MultiGroupDatabase/Sub    throw
  KeyPrefixedDatabase       IMPLEMENTED: Inner.Context.WithKeyPrefix(Prefix)
  RespDatabase (new)        the minimal one that actually works

KeyPrefixedDatabase is the interesting one: that single line is the whole write
half of what the class otherwise does by forwarding ~2600 lines of overrides. It
throws today only because its inner target does.

IRespExecutor is now internal, along with RespContext.Executor and WithExecutor.
Dispatch is an implementation concern and the public surface is the context plus
extension members; keeping it internal means the executor chain can be reshaped
later without that being a breaking change.

RespDatabase deliberately has NO command methods - Set, Get and everything after
are extension members over the context, so the type does not grow as the surface
does. That is section 9.4's argument made concrete rather than asserted.

Connection-backed types throw: routing a rendered frame through the existing
message pipeline is separate work and nothing here needs to wait for it.
RespMessageExecutor sends a pre-rendered frame via the existing message
pipeline - connection selection, backlog, multiplexing, failover all untouched.
Two small pieces: a Message whose WriteImpl is a blit, and a ResultProcessor
that captures the raw reply. The processor overrides SetResult rather than
SetResultCore, so it runs BEFORE the base MovePastBof() consumes the prefix and
length bytes the capture needs - the same reason ResultProcessor.RespResult does.

ONE message type covers every pre-formatted command. The library has 75
WriteImpl overrides across 20 files, and they exist purely because each command
shape writes itself differently; once the bytes arrive already framed there is
one shape. That is the clearest single measure of what moving formatting
upstream buys. It is scaffolding rather than a destination - the Message
machinery is expected to go away entirely in favour of execution life-cycle
state - and the type doc says so.

6 end-to-end tests against a real server: set/get, a missing key coming back
null, values written by the legacy API read by the new one, non-ASCII and
512-byte binary round-trips (which is really asking whether lengths were
computed in bytes rather than characters - the classic desync), the key prefix
landing on the wire and NOT under the bare key, and the cache serving a second
read.

That last one deliberately asserts a stale read: with no CLIENT TRACKING there
is no invalidation, so the cache keeps answering until told. Correct for a cache
nobody is invalidating, and precisely why tracking is next.

Everything before this was validated against fakes, which proves the shape but
never that a server accepts the bytes.
Verified rather than taken on trust: CommandFlags.Category.cs has a per-command
default-category switch, and TTL, PTTL, DUMP, TOUCH, PFCOUNT, EVAL_RO and
EVALSHA_RO all return CommandRetryReadOnly - so every one passes the caching
gate today.

Exclusions now grouped by WHY, which is more durable than a list of names:

  non-deterministic   SRANDMEMBER/HRANDFIELD/ZRANDMEMBER
  cursor state        SCAN/HSCAN/SSCAN/ZSCAN
  time-dependent      TTL/PTTL - the answer changes with the clock with no key
                      write, so NOTHING ever invalidates it; the same failure
                      class as a keyless command
  side effect is      TOUCH - bumps LRU/LFU, and a cache hit skips it, so the
  the point           command silently stops doing its job
  a read that writes  PFCOUNT - caches the computed cardinality back into the
                      HLL header

TOUCH is the sharpest case, because the codebase already holds the evidence that
the axes diverge: its category-table comment says the LRU/LFU bump is 'not a
real side effect worth blocking retries over'. Correct for retry, exactly wrong
for caching.

DUMP I would challenge rather than accept: it looks correctly invalidated - a
deterministic function of a tracked value - so excluding it is a benefit call,
not a safety one, and mixing 'wrong' with 'not worth it' makes the list harder
to trust.

Structural finding: the metadata table already exists. Cacheability belongs
beside the retry category in CommandFlags.Category.cs, not in a new structure.

Scripts recorded as unresolved: cacheability is a property of the script, not
the command name, so a blanket exclusion discards the cacheable majority while
cacheable-by-default is wrong rather than merely suboptimal. That may be the one
population justifying the explicit opt-in bit 6.9 set aside.

Notes only.
Resolved rather than left open, and the justification is stronger than 'the
caller knows best', which alone would be a weak default:

- EVAL/EVALSHA are ALREADY excluded by the gate - verified, they default to
  CommandRetryWriteAccumulating, well above read-only. So the population
  defaulting to cacheable is not 'scripts'; it is only scripts where the caller
  deliberately chose the _RO variant.
- _RO is server-enforced rather than conventional: a write attempt under it
  errors. The caller has already made a declaration and the server has already
  verified half of it.

The risk worth documenting is NOT non-determinism but UNDECLARED KEY ACCESS:
invalidation tracks the keys the script declares, so a script reading a key
absent from KEYS[] is never invalidated against it and stays stale silently.
Declaring every key touched is already mandatory in cluster, so this aligns with
existing practice rather than adding a rule. Recorded on CommandFlags
.NoClientCache as a third reason to reach for it, where callers will meet it.

Rejected: detecting it by inspecting the script. A regex - or anything short of
a Lua parser - loses to computed command names, pcall and string concatenation,
and a detector that is usually right is worse than a clear rule, because people
trust it; the failure it would miss is the silent, durable kind.

Notes only.
I wrote that EVAL/EVALSHA 'are already excluded by the gate'. They are excluded
by DEFAULT. WithDefaultCategory is explicitly a no-op when the caller already
supplied a category, and MaskRetryCategory is in UserSelectableFlags - so
passing CommandRetryReadOnly with a plain EVAL is honoured, and that call is
then cacheable.

That is intended rather than a hole. It is the same single rule the whole gate
rests on - the declared retry category, whoever declared it - applied with no
special case for scripts. And anyone who declares a writing script read-only has
already broken retry semantics more severely than they have broken caching.

Notes only.
RedisDatabase.Context is now real: a context carrying the command map, database
index and server type, with a RespMessageExecutor over this database. Built once
and cached, because the executor is a per-database object and minting one per
property access would allocate on a path meant not to.

So an end-to-end run is now:

    var db = conn.GetDatabase();
    await db.Strings.Set(key, "marc");
    var value = await db.Strings.Get(key);

with no cast, no executor and no context construction - where the tests
previously hand-wired all four.

RedisBase.Context still throws, so IServer and ISubscriber are untouched.
RedisDatabase hides it with 'new', which makes the interface mapping
load-bearing: if it ever resolved to the base, every extension member would
throw, since they all reach the context through IRespTarget. Asserted in the
test rather than assumed, because that is exactly the kind of thing someone
tidies away later.

Two more end-to-end tests: the zero-wiring path, and the database index really
reaching the wire (written to db 3, absent from db 0).
Asked whether the grouping structs should expose their context as a public field
rather than a property, to dodge a stack copy. Measured first: RespContext is 64
bytes, which is past where the JIT keeps a struct in registers, so the copy is
real - the premise was sound.

But the measurement points somewhere better. The 64 bytes are four references
(32), a CancellationToken (8), Database + ServerType (8), and RedisChannel
ChannelPrefix (16). A QUARTER of every context copy is a channel prefix that
only pub/sub uses and that Strings, Hashes and every other data-type group never
touch.

So: shrink the thing being copied rather than dodge one copy of it. Moving
ChannelPrefix into the services slot that already exists for optional
capabilities - or storing the byte[] the writer actually wants - takes the
context to 48 bytes and helps EVERY copy, including the ones inside Send, not
just the rare external .Context read.

Against the field specifically: the JIT inlines a trivial getter so partial uses
are usually forwarded anyway, and a public field locks the representation, which
is exactly what shrinking it would change. Style is not the objection - the
repo's .editorconfig sets SA1401 to silent.

Test pins that a group struct is the same size as the context it holds: the
wrapper IS the pun, and if that ever diverges someone has added a field to a
grouping type.
A quarter of every context copy was a RedisChannel carried for pub/sub's
benefit alone, while every data-type group ignored it. It is now a service,
resolved by a type test that only code writing a channel ever pays.

The slot became a CHAIN to make this work: one service was enough while the
cache was the only one, two are not. ServiceLink holds a service plus whatever
was already there, and adding PREPENDS, so the most recent of a type wins by
lookup order.

That removes code rather than adding it. 'Replace' needs none, because a later
add shadows an earlier one. 'Remove' needs none either: setting a prefix back to
default shadows it with an empty one that reads as absent, so the chain stays
append-only. An array would be copied on every add; a link is one allocation,
immutable, and shared by every context clone - and only exists from the second
service onwards, since a context with exactly one keeps the bare object.

Three tests: the 48-byte size, services composing rather than evicting each
other (with newest-wins and shadow-to-clear), and the prefix surviving unrelated
With* clones - which matters now that no clone names it explicitly.
SendAsync/Send now take the interpolated string directly - the ref is implied,
as Execute already did - with flags BEFORE the handler so the handler can be
omitted and resolved from TResult via RespHandlers.Inbuilt<T>. An unregistered
result type throws naming the type, at the call site rather than when a reply
arrives. TResult must be explicit: C# infers type arguments from arguments,
never from a return type.

The flags fix is a real defect, not tidying. Get/Set had
'flags = CommandFlags.CommandRetryReadOnly' as a PARAMETER DEFAULT, so a caller
passing FireAndForget silently REPLACED the category with nothing - losing the
retry semantics and, now, cacheability with it. People expect flags to add. The
default is now None and the category comes from WithRetryCategory at the call
site, which is first-wins so an explicitly named category still beats ours.

CommandFlagsExtensions became public for that: an external command surface
cannot express a default category otherwise. Only WithRetryCategory is public;
AsRetryCategory and WithScanCursorCategory stay internal. Renamed from
WithCategory, since 'category' alone is ambiguous in public API.

Found while testing it: the orchestration called Detach() without flags in three
places, so RespRequest.Flags was always None - meaning a retry executor would
have seen no category at all. Fixed, and now asserted rather than assumed.

The frame-based overloads stay public: Compose produces a frame and that path is
public, so making them internal would have broken conditional arguments for
external callers. RS0027 is suppressed with justification - it guards against
ambiguity, and these overloads differ by parameter type, so no call can be
ambiguous.
A sequence of AppendFormatted calls becomes one interpolated fragment, so the
optional part of a command reads the same as the rest of it - which is the point,
since the order of those calls was the caller's to keep straight.

The obvious implementation does not work. A handler holding
'ref RespCommandHandler' and forwarding each call fails on EVERY target with
CS9050, 'a ref field cannot refer to a ref struct' - a language rule, not a
down-level runtime gap, so narrowing the target frameworks would not have
helped. netfx adds CS9064 on top, but that is not the blocker.

Instead it MOVES: the command is copied into the handler, appended to, and
assigned back. Both structs reference the same pooled array during that window,
but only the copy is touched and the original is overwritten as the window
closes - including when a growth inside the window swapped the array, which is
exactly what separates a move from a share, and has its own test.

Two things make it compile, both with opaque errors: Append is an EXTENSION with
an explicit ref parameter rather than an instance method, because as an instance
method the compiler passes 'ref this' into the handler constructor and then
refuses the call (CS8350/CS8352); and that parameter is 'scoped', which is how
'this reference does not escape' is said. The call site is identical either way.

5 tests: conditional append matching the unconditional form, surviving a buffer
growth, several appends accumulating, and keys appended this way still being
marked for routing and invalidation.
Collapses RespAppendHandler away. RespCommandHandler gains a constructor taking
'scoped ref RespCommandHandler' that does 'this = command', and Append assigns
it back - so the command is moved in and moved out, with no second type.

That is safer rather than merely tidier. A proxy type needs its AppendFormatted
overloads kept in step with the command handler's, and the failure is quiet:
add one there without adding it here and cmd.Append($"{x}") just stops
compiling, with nothing to say why it works in the command and not the append. I
had written a reflection test to guard exactly that - mutation-checked, and it
did fire - but not needing the guard beats having it.

The move semantics are unchanged and still the interesting part: both copies
reference the same pooled array in the window, only the moved-in one is touched,
and the original is overwritten as the window closes - including when a growth
swapped the array, which is the case that separates a move from a share and has
its own test.

Also unchanged: Append is an extension with an explicit scoped ref parameter,
because as an instance method the compiler passes 'ref this' into the handler
constructor and refuses the call outright.
This branch is v4 work, so say so rather than carrying 3.2 through it.

Keeps -alpha: it costs nothing now and is what makes a preview build
possible without touching this file again. assemblyVersion goes to 4.0,
staying coarse for the same binding reason 3.0 was.

versionHeightOffset -1, scoped to 4.0 the way the previous one was
scoped to 3.2: without it the first commit of a version line lands on
4.0.1, which is a silly place to start. The height only becomes
meaningful at the squash merge in any case.

Note RESPite shares this file and moves to 4.0 with it - it was already
packing on the same line (3.2.x), so this keeps that arrangement rather
than introducing one.
A fake executor handing back one pre-built reply, retained per call so
the harness allocates nothing; empty and 1000x10, inline-completion and
forced suspension. These measure machinery only - an empty XRANGE round
trip is tens of microseconds and would bury the difference - so they
must not be compared with server-based numbers.

Three things came out of it.

The deferred view is the big win and is independent of runtime async: at
1000x10 the walk allocates 186 B against the array shape's 392,210 B,
about 2,100x, and is ~19% faster. That is the design's claim, measured
through the real API rather than a prototype.

Runtime async is worth much less through the real surface than a tight
loop suggested - ~9% on inline completion and ~48 B per suspension, not
2x - because the protocol work dominates the machinery.

And the control column paid for itself immediately: suspension went
819ns to 3014ns from net10 to net11 with runtime async OFF, so that
regression is .NET 11 or the harness on it, not the feature. Without the
control the wrong conclusion was right there, and it also explains the
earlier scratch result that had no control. It is not a finding yet -
3.7x with identical allocations is too large to believe from a short
in-process job on an RC runtime.
Marc spotted an ordering that cannot be true: the suspending walk
measured ~8% faster than the inline one. That was not noise - it
survived a full job at about twenty standard errors. Suspension cannot
make work faster, so the harness was wrong.

The fake executor suspended with Task.Yield(), which moves the
continuation to a thread-pool thread, so everything after the await ran
in a different threading and GC context. Replaced with an awaiter that
suspends and resumes inline, forcing the state-machine box to exist
without a thread hop.

Re-measured on net10 with a full job, the ordering is now monotone
everywhere and suspension costs a flat ~47ns and one box - agreeing to
within 5ns across three differently shaped benchmarks, which the old
numbers never did. The old figure was 819-940ns, so roughly 80% of it
was thread-pool scheduling.

Every net11 number recorded so far is therefore void, including the
"3.7x suspension regression", which sat entirely on the broken path.
What survives is the deferred-view result, which was never in the
suspend dimension, and the allocation column, which is counted rather
than timed.
With the fixed harness and a DeferredRead row that converts every field
the way ToNameValueEntry does - the apples-to-apples partner for
TransitionalArray, where DeferredWalk was only ever the traversal floor.

Runtime async helps the inline path by ~8% and hurts the suspending one
by ~22% and 224 bytes, consistently across all four shapes with errors
under 2ns. For this library that is the wrong way round: a real call
waits on a socket, so suspending is the common case and the inline win
only reaches cache hits and buffered pipelined replies. Not worth
adopting on RC1 evidence; re-test at GA. The wiring stays because it
costs nothing switched off.

.NET 11 itself is neutral - net10 and net11-off agree to ~1% everywhere,
which finally disposes of the "3.7x suspension regression" reported
earlier as the Task.Yield harness artifact it was.

And the deferred view is not faster, it is smaller. The earlier "18%
faster" compared a traversal against a materialisation. Doing the same
conversions, the deferred shape is ~5% SLOWER for ~2,100x less memory -
still the right trade, and the one the design intended, but the honest
claim is narrower: you stop paying for materialisation you did not need,
and the walk costs a few percent more than the single forward pass it
replaced.
RespFrame.Command is a stored property, written when the command hole is
appended - so Send always had what it needed and the call sites never
did have to say it.

Checked before moving it: 227 sites name a command literally, and a
scripted search for one declaring a category for a command it does not
render found zero real mismatches; the rest pass the same variable they
rendered. So this is behaviour-preserving.

WithDefaultCategory(request.Command) now happens in the four send entry
points, and 255 call-site calls are gone. Beyond the deletion, it means
a new command cannot arrive without a category - which retires the
analyzer idea, since you cannot forget what you no longer write - and it
makes an ordering structural that was previously convention: the cache
reads flags to decide what may be served or stored, so the category has
to be settled before PermitsCaching sees it.

The 18 argument-dependent refinements are untouched and still win,
because WithRetryCategory is caller-wins. Ad-hoc Execute is unchanged.

Four standalone assignments stay: each reads flags locally after setting
the category, for a feature probe or a routing demotion, and the second
call in Send is a no-op. Those want individual review, not a regex.

Also widens ExceptionFactoryTests.CanGetVersion, which pinned the major
to [2-3] and so failed on the v4 bump. A version assertion that must be
edited every major is asserting the wrong thing.
Two moves on one command. RangeCommand returns a RespFrame and is the
only place that picks XRANGE or XREVRANGE, swaps the bounds, writes the
optional count and validates - so the two overloads differ only in what
parses the reply. "Our magic command thing" needed no new type:
RespContext.Render already returns RespFrame and SendAsync already takes
one.

That duplication is not hypothetical - 44 distinct command texts are
currently written more than once on this surface, 48 redundant copies,
which is precisely what RedisDatabase's message factories exist to
prevent.

And RangeArray serves the array shape with a handler calling the same
ParseRedisStreamEntries, so the transitional shim is one expression with
no async, no using and no reply object. A custom ContinueWith was
considered and rejected: the baseline saves nothing, AsTask().
ContinueWith allocates more with scheduler hazards on top, and a pooled
IValueTaskSource must still end in AsTask() because IDatabase demands
Task<T>. The answer was not needing a continuation.

Measured against the projection: ~14% faster and a flat 56 B less on
every row, that 56 B being the reply object. The harness cannot see the
other half - both its methods are a single async layer, where the second
only existed in the real shim - so adding the separately measured ~120 B
per suspending layer gives ~176 B per call on a real round trip.

Cost: ToArray loses the incidental coverage it had from being how the
shim worked. It keeps its own tests, and both paths still call one
function so they cannot drift; only the breadth changes.
Two cases sharing a keyword. On the interpolated overload ref is
structural - it is how an interpolated string binds to the handler at
all, and RespCommandHandler is a ref struct whose Complete() moves
ownership out, which RespAppend.Append depends on. Invisible and not
droppable.

On the frame overload it is deliberate. Detach() nulls _buffer, and ref
carries that to the caller's variable, so a spent frame stays spent;
without it the caller's copy double-returns a pooled buffer, which is
the class of bug that surfaces somewhere unrelated. An alias defeats it,
but an alias is a deliberate copy - what it catches is the same variable
used twice, which is what a retry loop looks like.

Cost is smaller than it appears: ref needs an lvalue so the factories
must hoist, but only the ~44 command factories ever hold a frame; every
ordinary call site uses the interpolated overload.

The rule that goes with it: compute flags before rendering, so nothing
in the argument list can throw between renting the buffer and handing it
to the send. That window is the one real cost of the hoist and it closes
by ordering. It holds everywhere today.
XDELEX was the last duplicated command text in Streams - the lease and
array overloads each carried their own copy. Both now call
DeleteExCommand, including the at-least-one-id check, so the group has
zero duplicated command texts and the pattern has been exercised on a
second shape: an interpolation with a computed token and a length
prefix, not just a swap and an optional operand.

The local is cmd rather than req, because RespRequest is a different
type here - it is what Detach() produces and what reaches the executor -
so a req holding a RespFrame would be a false cousin of it. cmd also
matches what the factories are called.
The wrong two types looked like siblings. RespCommand is a verb;
RespRequestFrame and RespRequest are both the verb plus its arguments,
differing only in whether they are composed-and-owned or ref-counted and
ready to dispatch. Marc's distinction: "XRANGE" is a command, "XRANGE 1
4 NOLOOP AUTO" is a request - FLUSHALL being both is a degenerate
instance, not a counterexample, since the types still differ.

RespCommand therefore stays. So does RespRequest, on stability rather
than aesthetics: 65 of its references are in tests, mostly the 21 fake
executors implementing IRespExecutor, which is the interface an outside
adopter implements too. The cheap name to move was the one nobody
outside implements.

RespOwnedRequest was the obvious pairing and is wrong: RespRequest holds
a nullable lease where null means borrowed, so it can itself own.
Ownership is not the axis; dispatch-readiness is.

The rename also removed an ambiguity I had not noticed - RespFrameWriter
and RespFrameScanner use "frame" in its correct wire-structure sense, so
the interpolated RespFrame was the odd use of the word among its own
neighbours. 97 references across 22 files, none in the fakes.
Marc asked why RangeAsync carries no CancellationToken. It is not a
regression: there are zero CancellationToken references across the whole
command surface and zero on RespContext. The token came off the context
as recorded and went onto Send/SendAsync, but never reached any of the
~256 public group methods - all of them, uniformly.

The entry argued from db.Strings.GetAsync(key, flags, token), a
signature that does not exist, which is how a half-finished change came
to be ticked off. Now unticked.

The deadline is before SER010 comes off rather than now: adding an
optional parameter to an existing method is a binary break, so after the
experimental attributes are removed each of those methods needs a
permanent overload instead. Same set as the .Interpolated namespace.

But not by simply adding the parameter - this queue already reasoned
that a token which cancels nothing reads as configuration on a context
and as a promise on a signature, and 256 signatures that throw when
handed a cancellable token would be worse than none. What gates it is
what cancellation can mean at all, given a RESP request cannot be
recalled: the server still runs it and the reply still arrives, so the
only honest cancellation is "stop waiting", which desyncs unless the
pipeline tracks the abandoned reply. That belongs with the Message
refactor and decides whether the parameter should exist, not just when.
The agreed shape was CT on the right of the call signatures; the token
came off the context and onto Send/SendAsync but never reached the group
methods. All 14 Streams methods now take it and thread it to the send,
with the docs saying what is honoured today.

Also corrects my reasoning in the queue. I had argued that a RESP
request cannot be recalled, so the only honest cancellation is "stop
waiting", which would desync unless the pipeline tracked the abandoned
reply. Wrong on both counts: cancelling unsent work - backlog, retries,
re-dispatch after MOVED/ASK - is genuine cancellation, and abandoning
the await costs the connection nothing, because the pipeline already
matches replies to requests, so the reply still arrives and is parsed
with nobody waiting. I confused "nobody is awaiting this" with "nobody
is reading the socket".

And it is not an open question: cancellation already works in the
unmerged v3 spike, which is what makes putting the parameter on the
signatures now a deferred implementation rather than a promise we might
not keep. Until it lands, an already-cancelled token is honoured and a
merely-cancellable one throws with the reason named - the resolution
this queue already reached for the plumbing, now reaching the surface it
was always meant to reach.
… shape

The ValueTask-returning interpolated overload took only (request, flags)
and passed default inward, so the four void-returning commands -
MergeAsync, SetAsync, SetByIndexAsync, TrimAsync - had a token they
could not pass on. Same oversight as the surface-wide one.

Also records the whole dispatch surface: seven entry points splitting
into a frame form (the plumbing the command factories call, nothing
defaulted) and an interpolated form (the call site), plus two preamble
overloads for SCRIPT LOAD + EVALSHA that differ by who owns the
preamble - a freshly rendered frame versus the shared cached one.

And corrects a claim I made and acted on. I reported the sync Send pair
as having zero call sites and proposed deleting it; there are 24, in
RespEndToEndTests and RespClientCacheTests, missed because my search
required Send< or Context.Send( while the tests write ctx.Send(. The
deletion is reverted. The narrower observation stands - the sync frame
overload carries ~93 lines of cache logic against the async one's 47 and
no production code calls it - but whether v4 wants a public synchronous
dispatch path is a real question, not the open-and-shut one I presented.
StreamEntriesHandler becomes StreamTypesHandler, which will hold the
rest of the stream exotics - XCLAIM, XREAD and XREADGROUP all answer
StreamEntry[], and the XINFO shapes follow. Group-specific parses stay
out of RespHandlers, which is for types any command might answer with;
filling it with shapes only one group can produce makes it a dumping
ground.

Explicit interface implementations from the first one, because every
IRespHandler<T>.Parse has the same parameter list and differs only in
return type, which C# cannot overload on - so writing the first
implicitly would force churning it when the second arrives. Named static
accessors so call sites need no cast, matching how Float32Handler reads.

Records two things declined on the way. Lambdas: IRespHandler<T> is an
interface, so they need a delegate overload plus an adapter that either
allocates per call or meets a type test whose boxing wants measuring,
and five of the nineteen handlers could not be lambdas regardless.
Merging StreamEntriesHandler with RangeReplyHandler: mechanically fine,
but the two do not share a parse - one reads the whole reply, the other
retains the payload and parses nothing - and merging would displace the
retain-or-copy logic that RespReplyHandler exists to hold, which is also
the public door outside adopters use.
The borrowed counterpart to RedisKey, as RespValue is to RedisValue.
Three inputs - span of bytes, span of chars, memory of bytes - stored in
one field: memory collapses into the span case because nothing outlives
the call, and chars are reinterpreted with MemoryMarshal.AsBytes plus a
flag. The flag is explicit rather than inferred from emptiness, because
an empty key is legal in Redis and inferring would work until somebody
stored under "".

This is safe here and nowhere else in the library for two reasons the
linked PRs did not have. Roles are unambiguous, because overload
resolution is the role declaration - which is what #2844 foundered on.
And lifetimes do not arise, because the writer copies each hole into its
rented buffer before AppendFormatted returns - which is what #2578
foundered on.

Constructors rather than AsKey() extensions: an extension would offer
itself on every string and span in any file importing the namespace.

Corrects something I said earlier: a bare interpolated string is NOT
already fine as a key. It binds to AppendFormatted(RedisValue) through
the implicit conversion, so it gets no prefix, no invalidation mark and
no slot - silently, which is the failure this type prevents.

Down-level needs the char* overload for GetByteCount; System.Memory
supplies the span form of GetBytes everywhere but not GetByteCount.
I had drawn the line at dynamic-versus-fixed, reasoning that a
run-time-sized argument list forced the array form, where a ref struct
cannot go. Wrong: Compose/Append builds in place, and every Append is an
interpolated hole that consumes immediately, so a borrowed key is as
welcome there as in a fixed interpolation.

The real line is build-in-place versus hand-over-a-collection. Only the
last genuinely needs the storage it pays for, which means RespKey closes
#2844's intent rather than an adjacent one.
The old name was wrong on both axes. "Handler" named the C# mechanism -
the interpolated-string-handler pattern - rather than the job, and
interpolation is only one way to drive the type; Compose/Append/Complete
is the other, and that is a builder by any reading. The attribute stays
on the type, so the pattern is one click away for whoever needs it.

"Command" was the wrong noun by the distinction already settled here: a
command is the verb, a request is the verb plus its arguments, and this
accumulates both.

So the progression now reads as the three states it has -
RespRequestBuilder, Complete() to RespRequestFrame, Detach() to
RespRequest: accumulating, owned and complete, dispatched and shareable.

Builder is existing house vocabulary rather than an import -
CircuitBreaker.Builder, HealthCheck.Builder and MultiGroupOptions.Builder
already mean "accumulates, then produces". 136 references, 31 files.
The string constructor is not redundant with the span one: the implicit
conversion from string to ReadOnlySpan<char> only arrived in
netstandard2.1, so without it new RespKey("k") compiles on the newer
targets and fails on net461/net472/netstandard2.0 - a gap that builds
for whoever writes it and breaks for whoever consumes it.

The discriminator becomes Null/Blob/Clob rather than a bool. Two states
would have forced a choice between refusing null and silently writing to
"" - shared, legal, and almost never what a null variable meant. Three
states avoid it and fix something the bool had wrong: default(RespKey)
now agrees with default(RedisKey) in being null, where the bool made the
default an empty blob. A byte where a bool was, so it costs nothing.

Emptiness still cannot serve as the discriminator, because an empty key
is legal in Redis: "no bytes" must not mean "no key".

Expressing null is this type's job; writing one is the writer's. A RESP
request is an array of bulk strings with no null among them, so a null
key renders as empty exactly as RedisKey does - which is a property of
RESP2/3 request framing rather than of a key, and is not assumed
permanent.
The token was agreed onto the call signatures during the MSFT review,
reached Send/SendAsync, and stopped there. It now reaches all 13 groups:
253 surface methods take it and thread it to the send, with the doc line
saying what is honoured today.

Done as one pass rather than group by group because it is the only
deadline-bound piece - adding a parameter after SER010 comes off means a
permanent overload per method rather than an edit - and because doing it
before the file moves avoids editing the same content twice.

Two things the scripted pass got wrong and had to be corrected, both
worth knowing for the remaining work. A signature regex anchored to a
single line silently skipped every multi-line signature, which showed up
only as unresolved crefs pointing at parameter lists that no longer
existed - 65 methods. And threading the token by pattern rather than by
method scope reached into private helpers with no token in scope; the
fix was to find each modified signature's body span and thread only
within it.

A private Push helper deliberately keeps its tokenless signature, so its
inheritdoc cref was reverted to match.
The surface was one class, RespSurface, in fourteen partial files: RespSurface.Hashes.cs,
RespSurface.Lists.cs and so on. That made "where does a group live" a question about a file
naming convention rather than about the code, and it put every group's overloads in one
RS0026 blast radius.

Streams went first and is the shape the rest now follow: Groups/X.cs holds the group struct
and the accessor, Groups/X.Methods.cs the commands, Groups/X.Types.cs the group's own types
when it has any (Streams and SortedSets do). The accessor cannot live on the group class -
a member named Hashes inside a class named Hashes is CS0542 - so each group contributes its
own accessor partial to RespDatabaseExtensions, which means adding a group stays one file
and no central list can fall out of step.

SORT has no group of its own; it hangs off keys, so it is Keys.Sort.cs.

The RS0026 suppression moves with it, and is now a per-group claim that can be checked:
every member of the class is an extension method on one group type, so same-named members
are always candidates for the same call, and within a group they differ in a parameter with
no default. That is about a dozen methods with one receiver rather than about every command
in the library.

Shared helpers that were stranded by the split (AsFragment(ExpireWhen), used by both keys
and hashes) move to the RespSurface partial that remains; crefs that used to resolve within
one class are now qualified by the group that owns them.
The lease form, the array form and the writable-lease form of a command differ only in what
parses the reply, so the request was written two or three times per command. Where that text
carries a decision, the copies can disagree - and disagreeing about WITHSCORES or FIELDS
numfields changes the reply's shape, so the sibling that got it wrong fails to parse rather
than quietly returning something else.

Factored, following the XRANGE precedent: LPOS...COUNT and LMOVEM (lists), ARSCAN and
ARLASTITEMS (arrays), ZRANGE/ZREVRANGE by rank and ZRANDMEMBER...WITHSCORES (sorted sets),
VLINKS...WITHSCORES (vector sets), HRANDFIELD...WITHVALUES, and the whole
"FIELDS numfields field [field ...]" family in hashes - eighteen sites there alone, now one
factory named for the shape rather than for any one command, with the count derived from the
span instead of passed alongside it.

Bare texts - a command, a key, maybe a count - are left where they are sent. There is nothing
in them to get out of step, and hiding "GET key" behind GetCommand(ctx, key) costs the
legibility the interpolated surface exists for. The queue records the line and why.

The frame-based SendAsync now defaults its handler exactly as the interpolated overload does,
so a factory does not force call sites to spell out a handler they were happy to leave
implicit; RS0026 is suppressed on RespExecutor with the same argument the groups use, since
the overloads differ in a leading parameter that has no default.
The database is not part of a rendered frame - no SELECT is written - so where a request
lands is decided by the executor, not by RespContext.Database. WithDatabase replaced only
the property, so db.WithDatabase(1).Strings.GetAsync(key) read database 0 and reported
database 1.

Silent in the worst way: the client-side cache keys on executor.Database, so the cache
agreed with where the request actually went. Nothing disagreed with anything - the answer
was just wrong.

RespMessageExecutor can now be re-pointed at a different database over the same target,
which is what WithDatabase uses; an executor that cannot be re-pointed throws rather than
being carried along silently. A context with no executor yet is unaffected, since
WithExecutor comes later.

Found while costing the server group: DBSIZE takes a database, so "how does a context
change database" needed an answer, and it did not have one.
server.Server.DatabaseSizeAsync(db) is the first group on IRespServerTarget, which until now
was an interface with nothing bound to it. It hangs off RespServerExtensions, the server-side
twin of RespDatabaseExtensions, so the keyspace groups and the server groups stay separate -
server.Strings.GetAsync(key) still must not compile.

The name needed clearing first. A public type StackExchange.Redis.Server and a namespace
StackExchange.Redis.Server are both reachable as "Server" from inside StackExchange.Redis,
which is CS0435 - and the in-process test server had that namespace. The alternatives all cost
something the group would carry forever: Servers (reads badly), a class/accessor name split
(breaks the one-name-per-group rule the group file layout depends on), or folding DBSIZE into
a server-scoped Keys (a two-sided-group decision this does not need to force).

So the toy moved instead, with permission: assembly and package still StackExchange.Redis.Server,
code now in StackExchange.Redis.ManagedServer - the term AGENTS.md already used for it. Its
RespServer base class is RespServerBase, since RespServer is now the group struct.

DBSIZE takes the database explicitly. IServer defaults it to -1 and resolves that against the
multiplexer; a server context carries no database at all, and DBSIZE is database-scoped, so the
caller says which one. It is real routing, not a label: the command takes no operand, so the
count follows the SELECT the pipeline applies.

Pinned against IServer as the oracle, with two dedicated databases - one flushed, one holding
exactly one key. Mutation-checked: dropping WithDatabase throws, and pinning it to database 0
counts 124,447 where 0 was expected.
RespReader and RespValue could hand out bytes without allocating, but the only way to get
text was ReadString(), which allocates a string every time. CopyTo(Span<char>, Encoding? =
null) is the text sibling of CopyTo(Span<byte>), with the same promise: write what fits,
report how much. The encoding defaults to UTF-8 - what a server sends - and is a parameter
because a caller may have stored bytes that are text in something else.

It decodes through a Decoder rather than Encoding.GetChars, and both reasons are
correctness. A streamed value's chunks can split a multi-byte sequence down the middle, and
only a decoder carries the half-read sequence across the boundary; and GetChars THROWS on a
short destination where this has to truncate. The decoder is skipped entirely, no allocation
at all, when the value is one contiguous run and GetMaxCharCount says the target cannot
overflow - which is the ordinary case, and arithmetic rather than a pass over the data.

The tail is where the care is. Decoder.Convert throws when the destination cannot hold even
one character - it does not report "nothing fitted" - so once the target has less room than
one more byte could need, decoding moves to a small stack scratch and takes only whole
characters across. A surrogate pair is one character and is never split in half; a one-char
target still takes one char of ASCII rather than giving up. Both of those, and the
cross-chunk decoder state, have a test that fails when the guard is removed.

Like its byte twin and unlike ReadString, it keeps a verbatim string's txt:/mkd: marker: the
two CopyTo overloads have to describe the same bytes, or one of them is lying about the
value.
Marc asked whether TryGetSpan dodges the Decoder. It did, but only when GetMaxCharCount said
the target could not possibly overflow - and writing the test to prove it showed two holes,
both of which fire on ordinary input.

GetMaxCharCount is pessimistic by design: UTF-8 asks for length + 1. So a caller who sizes
the target by the payload's BYTE length - the obvious way to size it, and always enough for
UTF-8 - failed the check and took the decoder path over one hypothetical character. There is
now a second chance: GetCharCount is exact, allocates nothing, and costs one pass the decoder
path would have spent anyway.

The bigger one: contiguous means contiguous IN THE BUFFER, not "not streamed". An ordinary
bulk string that straddles two segments has no contiguous run either, and that is not a rare
event - it is whatever the socket handed us. The Resp test attribute replays every frame at
each split point, which is how this surfaced: the roomy-target test failed on Split:4 and
after. Short non-contiguous values now assemble on the stack and rejoin the contiguous path,
using the same 256-byte budget ReadString uses for the same job. A full buffer proves
nothing, so only a SHORT read is taken as "that was all of it".

What still costs a decoder is what genuinely needs one: truncation, and values too long to
assemble. The tests say so directly by counting GetDecoder() calls on a delegating encoding,
rather than asserting an allocation nobody can see - and the cross-chunk cases moved to a
400-byte streamed value, since the old ones are now linearized and no longer exercise the
decoder at all.
The audience is NRedisStack and anyone else shipping commands this client does not have.
docs/Extending.md lays out three levels - the ad-hoc ExecuteResp call, the same on a context,
and a command surface of your own - with a migration table off the original
Execute(string, object[]).

ExecuteResp with its ReadOnlyMemory<RedisKeyOrValue> already existed and is already
documented in docs/Execute.md; it is retained as level 1 and cross-linked, because it is the
only one of the three that is shipped rather than SER010-experimental, and it is the right
answer for a command called once.

Every sample compiles and runs, as RespExtensionAuthorTests: the group, the classic accessor
and the C# 14 extension-property spelling the doc offers as an alternative, a command, a
custom IRespHandler, and the ad-hoc escape hatch. SUBSTR stands in for the module command a
real extender would add - a genuine server command with no API here and not even a
RedisCommand entry, so it takes the unknown-command path exactly as JSON.GET would, and
being an alias of GETRANGE it brings its own oracle.

Two claims in the guide get tests rather than assertions, because both fail silently: that a
key hole is routed and prefixed where a value hole is not, and that an unknown command is
NOT retried unless the author opts in with WithRetryCategory.

Also here, found while writing it: the ad-hoc ExecuteAsync was left declared on the Strings
group by the group-file split. It is an IRespTarget extension with nothing to do with
strings; it moves back to the neutral RespSurface partial it came from. SER010.md still named
RespCommandHandler and RespFrame, which were renamed days ago.
The documentation led with db.StringGet on a flat interface, which is the spelling 4.0 stops
recommending. Every example that has a group equivalent now uses it - db.Strings.GetAsync,
db.Keys.DeleteAsync, db.Sets.AddAsync - across seventeen pages, and the docs are async-first:
the synchronous members still exist and are still supported, but a page that teaches them
teaches the failure mode SyncOverAsync.md exists to warn about.

docs/LegacyApi.md is the page for the old spelling: what changed and why, the mapping table,
the handful of renames that were not transcriptions, and an honest list of what is NOT on the
groups yet (streams reads, ScriptEvaluate, locks, Publish/Ping/Execute and a few others).
It opens by saying the old API is not going away, because that is the first question a reader
with a large codebase has.

Three things this shook out, all of which would have been wrong in a reader's editor:

- Sets.MembersAsync returns a ReadOnlyLease, not an array. RespLogging.md was writing
  (await ...).Length and leaking the lease; the examples now hold it in a using.
- The groups return ValueTask, so the profiling examples adding one to a List<Task> would not
  compile - they now say .AsTask(). PipelinesMultiplexers.md gains a short note on the two
  rules that matter when you hold one rather than awaiting it immediately.
- db.Wait(pending) cannot take a ValueTask; the pipelining example is now start-then-await,
  which demonstrates the same thing and is what people actually write.

Streams and Scripting keep the original spelling with a note saying why: streams are half
moved, and a page that switched styles mid-example would teach neither well.

DocsSurfaceTests parses every db.Group.Member( out of the markdown and demands it resolves,
because none of the above is checked by a compiler. It found nothing today - it exists so
that a rename tomorrow does not quietly make the documentation wrong.

Queued: the Experimental attributes now need to come off, since the docs no longer describe
this as an experiment.
SER010 gated the interpolated writer and the command groups while the shape was being argued
with. 4.0 ships them as supported API, and the documentation now teaches them, so the gate was
the only thing still calling it an experiment.

Retired rather than merely un-attributed: 67 [Experimental] attributes removed, 612 [SER010]
prefixes off the public-API file, the ID moved to the reserved-and-never-reused list beside
SER002/3/6, docs/exp/SER010.md rewritten to say the experiment is over and any suppression can
be deleted. The proof is that SER010 leaves the repo-wide NoWarn and the whole solution still
builds: nothing anywhere is suppressing it.

Two things fell out of removing the gate, both of which it had been hiding:

- An experimental symbol used INSIDE another experimental symbol is exempt from the diagnostic,
  so RespFragment.Parse had been quietly building a fragment through the SER011-guarded
  constructor. It now says so, scoped to the single statement - and that call site is the
  sanctioned one, since the validation immediately above it is exactly what the guard demands.
- SA1506 began firing where a doc comment is followed by a comment block: the attribute used to
  break the run, and without it a blank line inside the block reads as a blank line after the
  documentation.

SER011 stays, and is not the same kind of marker: it guards hand-constructing a RespFragment,
which is a speed bump on a sharp tool rather than a maturity gate.

SER012 is now the open question and it is not independent: taking it out of NoWarn produces
1,174 diagnostics, because RespValue is the element type of the leases the groups return.
A supported surface whose return types are experimental is not a coherent position. Queued.

Also queued, found while running the analyzer tests: 18 of them fail on HEAD, and CI never runs
that project.
"Everything on a server context is a server item, by definition" - so a group called Server
repeats the receiver and names nothing, and server.Server.DatabaseSizeAsync(0) stutters for
exactly that reason. IServer has ~70 members falling into families - config, cluster, sentinel,
latency, memory, clients, slowlog, replication, scripts, keyspace - so one Server group would
have been the flat interface again, one level down. Those families arrive as their own groups;
keyspace is the first, and DBSIZE belongs to it.

The name was meant to be Keys, per this file's own plan that a few group names would appear on
both sides - key-routed for DEL, server-scoped for DBSIZE. It does not compile: IServer has
shipped a *method* called Keys since forever (the KEYS/SCAN enumerator), and a member and an
extension property cannot share a name, so server.Keys.CountAsync(0) is CS0119. Renaming the
shipped method is a binary break.

Keyspace is the better name anyway: DBSIZE, KEYS, SCAN, FLUSHDB and SWAPDB all describe a
node's whole keyspace rather than any key in it. The two-sided-group prediction therefore
fails for Keys specifically, and the reason is shipped API rather than taste - Scripts is still
free to divide that way, but the shipped IServer members decide it, so check them first.

The server accessors hang off IRespServerTarget only, never a bare RespContext - unlike the
keyspace ones. A context does not know whether it is pinned to an endpoint, and two extension
properties of one name on RespContext would be ambiguous at every call site anyway.

The toy's namespace rename is now a footnote rather than a requirement: nothing is competing
for StackExchange.Redis.Server any more. It stays, because a namespace and a type both
reachable under that name is a landmine for whatever wants it next.
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