Conversation
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 TRACKINGitself, 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
Messagemachinery is a deletion rather than a reconciliation — butIDatabasestays, served by the new core.
Messageitself stays too: it is abstract over exactly two members(
ArgCount,WriteImpl), so the core is already pluggable at precisely the rendering step; what goes isthe 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:
costs a render and no allocation.
writer-side semantics, and invalidation needs it. The writer records it as it writes. This is the
load-bearing fact.
must render byte-identically or the cache silently stores duplicates.
The write path
At no point is a string built — the compiler lowers each hole into
AppendFormatted, writing UTF-8straight into a pooled buffer.
$"SET {key} {value}"renders identically; whitespace contributes nothing.Optional arguments are holes, not branches.
Expiration.DefaultandValueCondition.Alwayscontribute no argument, so all of
SETis one interpolation:That replaces a ~17-branch decision tree in
RedisDatabase.GetStringSetMessage, most of whose branchespick between fixed-arity
Message.Createoverloads. Arity is free here:*Nis accumulated while writingand back-filled into a reserved prologue.
One pass also folds the cluster slot (
MULTIwhen keys disagree), the key marks, and key andchannel prefixes.
Appendexists for a genuine caller-side branch; the append handler is the commandhandler, moved in and back out, so both accept the same things by construction.
Fixed tokens (
EX,NX, container subcommands) are pre-framedRespFragments carrying anArgCount, since multi-token is the common case (~140 call sites). Hand-construction is gated behindSER011— deliberately not inNoWarn— because malformed bytes desync every subsequent command.Extending the vocabulary: extension
AppendFormattedmethods do not bind in a hole (verified on thecurrent compiler), so the vocabulary would be closed and closed to us.
IRespArgument/IRespFormattableArgumentare the way back in — an interface on the argument, deliberately unrelated soimplementing 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 payloadand 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 invalidationarriving 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
awaitor 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");NoClientCachesuppresses the probe as well as the store; andwith prefixes configured, any key outside them is refused, because otherwise there is no invalidation path.
Bounded:
MaxBytes/MaxEntrieswith sampled eviction,MaxPayloadBytes, periodic sweep, andcounters (
Stored,RefusedRaced,RefusedNotTracked, …) because the failure mode this design can stillproduce is silent and durable.
Config splits in two:
CacheOptionsis global per multiplexer (prefixes, tracking mode, quotas);CachePolicyis per-context (TTL, grace).RespClientCacheitself is internal — only the multiplexercan mint one, because a cache the caller attached by hand has no
CLIENT TRACKINGbehind it and wouldfill and never invalidate. The public opt-out is
ctx.WithoutCache().End to end:
ServerEndPointnow issuesCLIENT TRACKING ON [BCAST] [PREFIX …]during the handshake,built from
CacheOptions, and refuses loudly without RESP3 rather than caching without invalidation.PhysicalConnectionroutes theinvalidatepush. The in-process server models tracking too, so the testsexercise the library's negotiation rather than their own.
The command surface
Eleven groups so far (
Strings,Hashes,Sets,SortedSets,Lists,Bitmaps,Keys,Scripts,HyperLogLog,Geospatial,VectorSets), one file each. Three reasons: discoverability (IDatabaseis a flat wall of several hundred methods);module libraries become first class (
ctx.Search.Query(...)composes exactly likectx.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
Asyncsuffix stays even though only the async API exists. The earlier spelling dropped it on thegrounds that there is nothing to disambiguate from; that was wrong for the ecosystem it lands in — analyzers,
ConfigureAwaitguidance and readers all key off the name, anddb.Strings.Get(key)returning something youmust await reads as a bug at a glance.
Commands are ordinary
this inextension methods, not extension blocks, and that is about retirement:deleting the
thisun-binds new call sites while compiled callers keep working. Only the group accessorsare 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.Downleveland 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 stricterthan reality, and was the first round's mistake): Mono/
net472at 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
Downlevelalone (importing both isCS9339, a compileerror, 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) carriesthe keyspace groups;
IRespServerTargetis for server-scoped ones. Previously every group bound toIRespTarget, whichIRediscarried — soserver.Strings.Get(key)compiled, on a type pinned to oneendpoint. 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
ExecuteAsyncis overriddenon both.
No arrays on the new surface —
ReadOnlyLease<T>and friends instead, with a parallel internalextension where the old API still needs an array. Multi-value replies go further and become windows into the
reply buffer:
MGETof a thousand 32-byte values allocated 56,656 bytes before, 736 after. The array wasnever the expensive part — a
RedisValuehas 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
WithKeyPrefixonIDatabasealready does by folding the two prefixes into one decorator; and channel prefixes now do too. Thatlast 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
RedisKeyorunwrap a decorator.
So the prefixes are spelt
AppendKeyPrefix/AppendChannelPrefix, notWith*.Withreads as "the resultdiffers in this respect", which invites "so the second call wins" — the exact misreading that had just been a
live bug.
Appendrather thanPrependbecause the new prefix lands nearest the key:AppendKeyPrefix("a").AppendKeyPrefix("b")sendskasabk. Everything else keepsWith*, which is now areal distinction:
WithDatabase/WithServerTypereplace a value,WithCache/WithScriptCacherebind acapability by shadowing, and only the prefixes accumulate. The shipped
DatabaseExtensions.WithKeyPrefixonIDatabasekeeps its name — it behaves the same way, and renaming it would be a source and binary break tofix 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
CancellationTokenargument onExecute/ExecuteAsync— and sincethe pipeline cannot yet cancel an in-flight request, a real token throws
NotImplementedExceptionrather thanbeing silently ignored. An already-cancelled token is honoured properly, because that one can be: the command
is recycled and
ThrowIfCancellationRequestedfollows, so an obvious no-op does not leak a pooled buffer.Scripts: composition, not a special frame
EVALSHAis always issued, withSCRIPT LOADcomposed in front of it when needed. The alternative — aframe that re-spells itself as
EVAL <body>after aNOSCRIPT— would have been the first exception toa 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 LOADonce (exact-sized array, pooled rent returned). At writetime 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 aMOVEDmust re-decide. The belief is the endpoint's existing one, shared with the classic path.IMultiMessage.GetMessagescan now returnnullfor "nothing to compose, write me normally", so thecommon case does not build an enumerator to carry a single element.
Transition
RespFrameWriterrenders realMessageobjects intoRespFrames, so the existing surface feeds the newpipeline without being rewritten; both routes render byte-identically (pinned, because that is a cache
correctness property). The integration is one hook —
MessageWriter.Write(in RedisKey)reports theoffset, 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.TransitionalDatabaseimplementsIDatabaseover the new surface;SER352reports the members stillunimplemented (currently 186) so the gap is a build warning rather than a surprise.
Status
Full
dotnet build Build.csproj -c Releaseclean on all six target frameworks; complete suite green(7514 passing). Frames are validated by parsing them back with RESPite's
RespReaderandDemandEnd(), so over- and under-run both fail. Works onnet461/netstandard2.0— handlers arecompiler lowering, and a source polyfill covers the attributes.
Measured, not argued: the zero-allocation cache hit;
OnInvalidatecost and flatness; theMessageWriterhook A/B; theMGETallocation 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,
ExecuteAsyncblocks the calling thread for 508ms with one condition versus 2ms with none, i.e. two roundtrips done synchronously before you are handed a task to await. That is thread-pool starvation shaped, and it
is the number the
WATCHwork 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 TRACKINGend to end; theIServercontext; the keyspace/server target split; three more commandgroups;
SetResultsplit into inspect-vs-parse (6 overrides vs 89), which movedNOSCRIPTretry into thepipeline and deleted eight
catchsites — and fixed a real bug on the way, since the frame path never noticed-NOSCRIPTand so could hold a stale script belief permanently;IRespHandler.Parse(ref RespReader)takingthe positioned reader rather than a span; the
Asyncsuffixes, per-call cancellation and the down-level shimsabove; and errors stay exceptions — the
redis.callvsredis.pcallquestion — because an errors-as-valuesopt-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 hadhappened, but nothing prevented one, and a pair that disagreed would be invisible: both forms succeed and
return the length you expected.
HIMPORTprobed, and it is not a third seam.GetMessagesis called fromWriteMessageInsideLockwiththe
PhysicalConnectionin hand, which is exactly the "inject once the connection is known" the bridge'shard-coded
HIMPORTinjection has — soFramePairMessage+IRespPreambleGatecovers it with no newmechanism, and the only difference from
EVALSHAis 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
PREPAREreturns still reads "not prepared". Measured under a contendedburst of 8: confirm-on-reply injects 8 preambles, claim-on-write injects 1. Both are correct —
PREPAREis 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.
IDatabaseAsynccarriesIRespKeyspaceTarget, sotran.Strings.SetAsync(...)binds directly. That is a required-member break for anyone implementingIDatabaseAsync/IBatch/ITransaction, taken deliberately: extending this family has always been the onlyway 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
EXECpositions, and by structure rather than a sixth hand-written guard:
QueuedMessagedefault-refuses anythingwhose
CanWriteWithoutExpansionis false. That refusal is worth more than it looks — flipping the flag doesnot 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.
RespReplyis a disposable base for replies whose contents pointinto the buffer they arrived in;
Streams.RangeAsyncis the first command on it, and the deferred shape is adeliberate 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.)
RespReplyallows externalconstruction, so NRedisStack-shaped consumers can derive rather than wrap.
RespKey: a borrowed key. For the ad-hoc/IRedisKeyquestion from #2578 and #2844 - a key that can be aspan, a
ReadOnlyMemory<byte>, a string or null, with no allocation. Null is a third state rather than anempty payload, which also fixed something nobody had noticed:
default(RespKey)now matchesdefault(RedisKey)in being null.$"{someString}"does not get key handling, by the way - it binds to theRedisValueoverload, 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
SER010comes off - which is the deadline that matters, not "later".A group is now its own files. Thirteen groups moved from
RespSurface.<Group>.cspartials of one class toGroups/<Group>.cs(group type + accessor) +.Methods.cs(+.Types.cswhere the group owns types).SORThas no group, so it isKeys.Sort.cs. The accessor cannot live on the group class -CS0542- so eachgroup contributes its own partial to
RespDatabaseExtensions, which means adding a group is one file and nocentral list can drift. The
RS0026suppression moves with it and becomes a per-group claim that can actuallybe 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 thelegibility this surface exists for.
A silent wrong answer, found and fixed:
WithDatabasedid not change database. The database is not part ofa rendered frame - no
SELECTis written - so routing is the executor's, andWithDatabaseonly replaced aproperty:
db.WithDatabase(1).Strings.GetAsync(key)read database 0 and reported 1. Worse than usuallysilent, 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
DBSIZEwould need.Naming, and a version.
RespFrame->RespRequestFrameandRespCommandHandler->RespRequestBuilder:XRANGEis a command,XRANGE 1 4 COUNT 10is a request.version.jsonsays 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 bewritten where a global property cannot outvote it, because the obvious spelling silently put
net11.0in the.nupkg. On RC1 it helps the inline path ~8% and costs the suspending path ~22%, so: revisit at GA. (Theharness lied first:
Task.Yield()moved continuations to the thread pool and made suspending look fasterthan inline, which is impossible. Marc spotted it.)
The first server-scoped group exists:
server.Server.DatabaseSizeAsync(db), onIRespServerTarget-an interface that had been declared with nothing bound to it - reached through
RespServerExtensions, theserver-side twin of
RespDatabaseExtensions. Getting the name cost a rename: a public typeStackExchange.Redis.Serverand a namespaceStackExchange.Redis.Serverare both reachable asServerfrominside
StackExchange.Redis(CS0435), and the in-process test server had that namespace. Rather than callthe group
Serversor split the class and accessor names, the toy moved: assembly and package stillStackExchange.Redis.Server, code now inStackExchange.Redis.ManagedServer.DBSIZEtakes its databaseexplicitly - a server context has none of its own - and that is real routing, since the command takes no
operand and follows the
SELECTthe pipeline applies.Next, in likely order: the arity-2 walker that finishes
the row-parser collapse (
HashEntry,SortedSetEntry); wiring the realHashImportfield-set through theframe path so the bridge's
if (cmd is RedisCommand.HIMPORT)type test can retire; more command groups;ISubscriber's context; and dropping the.Interpolatednamespace, which is free whileSER010is on.Blocked, and honestly so:
WATCH/MULTI. The write loop pauses mid-transaction to decide betweenUNWATCH,EXECandDISCARD— and the fix is not a redesign of that pause, it is theMessage/write-looprefactor that removes the pulse the pause waits on. Designing around a mechanism already scheduled for
deletion would be wasted work, so this one waits.