From aeb3d112a50c4967f3d2021a6bd44c662eda67f0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 14:30:43 +0100 Subject: [PATCH 001/360] Add design notes for the interpolated-string RESP writer Records an exploration of using a custom interpolated string handler to build RESP frames - $"{cmd}{key}{value}" - as the basis for the v3 writer work. Everything marked "verified" was compiled across netstandard2.0/net472/net8.0 and, where runtime behaviour was in question, run; generated frames were parsed back with RESPite's own RespReader (DemandEnd() enforcing exact consumption). Main findings: - The handler pattern is fully polyfillable down-level - it is 100% compiler lowering and the marker attributes are matched by name. Same for u8 literals, C# 14 extension members, OverloadResolutionPriority and scoped. InlineArray is the one thing that is not. - Banning literal segments ([Obsolete(error: true)] on AppendLiteral) makes formattedCount the argument count, so *N is a compile-time constant. - The AppendFormatted overload set is closed: extension methods do not bind, in any of the three forms. So it is a permanent API commitment, and a generic catch-all would silently ToString() anything not explicitly declared. - Key marks as an MSB-discriminated ulong: two 31-bit byte offsets resolved with no scan, falling back to an arg bitmap. Offsets must be buffer-absolute, since Close() right-aligns the header and moves the frame start. - Because the frame doubles as the cache key, canonicality becomes correctness; Resp.Raw is the hole, closed by allowing .Resp() only on literals and validating them in the analyzer (which does ship to consumers). Open questions and the verification log - including what was not tested - are in the document. --- design/interpolated-resp-writer.md | 453 +++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 design/interpolated-resp-writer.md diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md new file mode 100644 index 000000000..8927ad232 --- /dev/null +++ b/design/interpolated-resp-writer.md @@ -0,0 +1,453 @@ +# Interpolated-string RESP writer + +Design notes for the v3 writer work. **Nothing here is implemented** — this records what was +verified empirically, what follows from it, and what is still open. + +The idea: let command construction read as + +```csharp +Execute($"{cmd}{key}{value}", handler); +``` + +where `$"..."` binds to a custom interpolated string handler that writes RESP directly, rather than +building a `Message` + argument array. The handler is pure formatting: it runs on the caller's +thread, ahead of the critical section, and the bytes it produces double as the client-side cache +key before anything touches the muxer core. It is never near a connection. + +--- + +## 1. Is the handler pattern usable down-level? + +**Yes, with no runtime support at all.** Interpolated string handlers are 100% compiler lowering, +and the marker attributes are matched *by full name*, so declaring them `internal` in our own source +works exactly like the existing `SkipLocalsInit` (`src/RESPite/Shared/SkipLocalsInit.cs`) and +`IsExternalInit` (`src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs`) shims. +`LangVersion 14` is already set repo-wide in `Directory.Build.props`. + +Verified by compiling across `netstandard2.0` / `net472` / `net8.0`: + +| Feature | Down-level | Notes | +| --- | --- | --- | +| `[InterpolatedStringHandler]` on a `ref struct` | works | polyfill the attribute in source | +| `[InterpolatedStringHandlerArgument(nameof(x))]` | works | caller's `stackalloc` buffer reaches the ctor | +| `[InterpolatedStringHandlerArgument("")]` | works | `""` passes the **receiver** (`this`) | +| `[InterpolatedStringHandlerArgument("", nameof(cmd))]` | works | receiver *and* a parameter | +| `out bool shouldAppend` conditional ctor | works | | +| `bool`-returning `Append*` | works | compiler emits a short-circuiting `&&` chain | +| Use inside an `async` method | works | fails only if a hole itself contains `await` (CS8850) | +| `u8` literals (`"..."u8`) | works | **no polyfill needed**; needs only `ReadOnlySpan` | +| C# 14 extension members (`extension(...) { }`) | works | also pure lowering | +| `[OverloadResolutionPriority]` | works | polyfill the attribute in source | +| `scoped` on span params | works | `ScopedRefAttribute` is compiler-synthesized | + +`net461` was not tested (no reference assemblies to hand) but uses the same compiler path; the only +dependency is `ReadOnlySpan`, which RESPite already has there via `System.Memory`. + +**`InlineArray` is the one thing that is *not* polyfillable** — it needs .NET 8+ runtime layout +support, and down-level the attribute is inert, silently giving a one-element struct. Use +`stackalloc` at the call site or `fixed` buffers instead. + +--- + +## 2. Shape + +### 2.1 Literals are banned + +`AppendLiteral` is declared but marked `[Obsolete(..., error: true)]`, so every part of the command +must be a hole: + +```csharp +// error CS0619: All parts must be holes: $"{RedisCommand.SET}{key}{value}" +$"SET{key}{value}" +``` + +Two options were compared. *Omitting* `AppendLiteral` also rejects literals, but produces a +confusing pair of diagnostics (`CS1061` plus a bogus `CS8941` "does not return void or bool"). +`[Obsolete]` produces one error carrying our own message. Use `[Obsolete]`. + +**Why ban them:** with no literal segments, the compiler-supplied `formattedCount` *is* the argument +count, as a compile-time constant — so `*N\r\n` can be written in the constructor with no counting +and no back-fill. + +**Verified non-hazard:** C# 10 makes an all-constant interpolated string a *constant expression*, so +`$"{"GET"}{"mykey"}"` could in principle have been folded to a single literal and routed to +`AppendLiteral`, silently corrupting the frame. It is not — the handler conversion wins and each hole +stays a hole. Confirmed both at runtime and by the fact that it compiles against a handler that has +no `AppendLiteral` at all. + +**Non-interpolated strings do *not* bind to the handler.** If a `string` overload exists alongside, +`Write(buf, "plain literal")` silently takes it while `Write(buf, $"GET {key}")` takes the handler. +Either don't provide a `string` overload, or accept that callers must write `$"PING"`. + +### 2.2 The overload set is closed — permanently + +Extension `AppendFormatted` methods **do not bind**. Verified three ways, all rejected in a hole +while compiling fine as ordinary calls: + +```csharp +public static void AppendFormatted(this ref H h, Geo v) // no +public static void AppendFormatted(this H h, Vec v) // no +extension(ref H h) { public void AppendFormatted(Blob v) } // no (C# 14 extension block) +``` + +The lowering does member lookup against instance members declared on the handler type and stops. +So nobody — not a consumer, not another assembly here — can extend it after the fact. + +Consequences: + +- Prefer a **few correct funnels over an enumeration**. Adding overloads later is additive and safe; + removing or retyping them is breaking (AGENTS.md). Ship the minimum set. +- The funnels: `RedisCommand`, `RedisKey`, `RedisValue`, `Resp.Raw`. +- **Do not define `AppendFormatted`.** A generic catch-all is an exact match by inference, so it + beats any overload needing a conversion — anything not explicitly declared silently falls into a + `ToString()` path and goes on the wire wrong. Omitting it makes those compile errors instead. + (Cost: the resulting `CS1503` names an arbitrary overload from the set. Analyzer candidate.) +- With no catch-all, `RedisValue`'s existing implicit conversions cover `string`, `int`, `byte[]` + etc. for free. + +A clean rule for the two byte-ish funnels: + +- **`Resp.Raw` = "I already framed this"** → explicit, because the compiler can't check the claim. +- **`RedisValue` = "you frame this"** → implicit is safe, because the framing is ours. + +Note `Resp.Raw` is a `ref struct`, so it is *structurally* incapable of falling into a generic +catch-all even if one were added later. + +### 2.3 `Resp.Raw` and `u8` + +Pre-framed fragments enter as `Resp.Raw`, a `readonly ref struct` over `ReadOnlySpan` plus an +`ArgCount`. Reuse goes through static **properties** (a `ReadOnlySpan` cannot be a field): + +```csharp +public static Raw Ex => "$2\r\nEX\r\n"u8.Resp(); +public static Raw ExpireSeconds300 => "$2\r\nEX\r\n$3\r\n300\r\n"u8.Resp(2); +``` + +Zero allocation; inlines to an RVA load. + +`ArgCount` is the reason the wrapper exists rather than a bare `ReadOnlySpan`: **a raw fragment +can be more than one bulk string**, so without it `formattedCount` stops equalling the argument count +and the constant `*N` header silently breaks. Measured: a 2-arg fragment in a 3-hole interpolation +yields 4 args. + +An implicit `ReadOnlySpan` → `Raw` conversion was tried and rejected: it re-opens the hole it +was meant to close, because *any* span — including a runtime `byte[]` payload — then claims to be a +framed fragment. The analyzer can catch bad *literals*, but the conversion's new risk is non-literal +spans, which is exactly what it cannot see. `.Resp()` costs seven characters and is the whole +assertion. + +Ship `Resp()` and `Resp(int argCount)` as **separate overloads**, not one optional parameter — +adding an optional parameter later is a binary break (AGENTS.md). + +### 2.4 `RedisCommand` and CommandMap + +The command must be a `RedisCommand` so it routes through `CommandMap` (renaming/disabling per server +type). The plumbing already exists: `CommandMap.GetResp(command)` returns a **pre-encoded RESP +bulk-string fragment** (`$6\r\nLRANGE\r\n`) from one shared ~3k buffer, already uppercased +(`CommandMap.cs:225`, built at `CommandMap.cs:248-270`). So `AppendFormatted(RedisCommand)` is a +lookup and a blit. + +Two consequences: + +1. **It forces the receiver-passing form.** `CommandMap` is per-`ConfigurationOptions` and resolved + at runtime, so no static lookup is possible. +2. **It rules out the `bool` short-circuit pattern.** A disabled command must *throw*, not return + `false` — returning `false` would abandon the remaining holes and emit a truncated frame. Use + `void` Append methods. + +--- + +## 3. Two call shapes + +### 3.1 Argument form — preferred + +```csharp +public Resp Begin(RedisCommand command, + [InterpolatedStringHandlerArgument("", nameof(command))] ref Resp handler) => handler; + +using var r = writer.Begin(RedisCommand.SET, $"{key}{value}"); +``` + +`("", nameof(command))` passes **both** the receiver and the `command` parameter into the constructor, +so the CommandMap, the command and the target are all known up front. + +### 3.2 Conversion form + +```csharp +using Resp r = $"{RedisCommand.SET}{key}{value}"; +``` + +This is an interpolated string *conversion*, not an argument, so `[InterpolatedStringHandlerArgument]` +does not apply and only the `(int literalLength, int formattedCount)` ctor runs. The handler therefore +loses the receiver, which means: + +- it must own a pooled buffer; +- the CommandMap has to arrive at `Close(map)`; +- the prologue must be a **fixed worst-case** reservation, since `map.MaxRespLength` is not available + yet. (Hit as an `ArgumentOutOfRangeException` while building this.) Not a real cost: `*N` is ≤12 + bytes and command names are bounded. + +Prefer the argument form. Its terseness advantage is small and the receiver is the thing you need. + +--- + +## 4. Deferred composition + +For conditional arguments: + +```csharp +using var r = writer.Begin(RedisCommand.SET, $"{key}{value}"); +if (withTtl) { r.AppendFormatted("EX"); r.AppendFormatted(300); } +if (withNx) r.AppendFormatted("$2\r\nNX\r\n"u8.Resp()); +var span = r.Close(); // back-fills *N into the reserved prologue, right-aligned +``` + +Verified output (parsed back with RESPite's `RespReader`, `DemandEnd()` enforcing exact consumption): + +``` +*6|$3|SET|$5|mykey|$7|myvalue|$2|EX|$3|300|$2|NX| +*5|$3|SET|$5|mykey|$7|myvalue|$2|EX|$3|300| +*2|$3|GET|$5|mykey| +``` + +Also passing: empty bulk string, multi-byte UTF-8, a 5000-byte payload forcing a pool regrow *mid-build* +(after the prologue is reserved), negative integers, and 22 args forcing a two-digit `*NN` header. + +**The trade:** conditional appends make the total arg count runtime-only, so the compile-time-constant +header is lost. Keep the single-expression path alongside for fixed-arity commands, where `*N` stays +constant. + +`using` works on both forms, and matters here: arbitrary user code sits between construction and +`Close()`, so a throw in that window leaks the rented buffer. + +**Gotcha:** `using var` cannot be passed by `ref` (CS1657). Mark resolution members `readonly` so `in` +works, or callers are forced into `try`/`finally`. + +--- + +## 5. Key and slot accumulation + +Needed for routing (cluster slot) and client-side cache invalidation. + +### 5.1 Routing is free + +Slot folding is O(1) state — one `int` — using the same logic as +`ServerSelectionStrategy.CombineSlot` (`ServerSelectionStrategy.cs:272`). No key storage at any arity. + +Better still, the handler can fold the slot over **the bytes it just wrote**, rather than +re-materializing the key. `RedisKey.CopyTo(Span)`/`TotalLength()` write straight into the +output buffer; `GetHashSlot` today has to copy the key into a separate scratch buffer purely to hash +it (`ServerSelectionStrategy.cs:67-92`). + +Keyspace-isolation prefixes must be applied **before** both the write and the slot. + +Verified: + +``` +zero keys slot=NoSlot keys=0 +one key slot=10778 keys=1 [user:1] +three keys, shared hashtag slot=4574 keys=3 [{u1}:name, {u1}:age, {u1}:email] +three keys, cross-slot slot=MultipleSlots keys=3 [alpha, beta, gamma] +prefix: slot(user:1)=10778 slot(tenant7:user:1)=11022 (differ) +``` + +### 5.2 Key marks — an MSB-discriminated `ulong` + +Measurement: testing a bitmap costs **nothing** — recovering one key of N and recovering all N are +indistinguishable. The entire cost is the RESP walk (~40–100 ns for typical small frames, ~400 ns at +65 args), versus single-digit ns for a direct slice from a stored offset. So the axis that matters is +**scan vs no-scan**, not bitmap vs offsets. + +``` +MSB clear → [ offset_b:31 | offset_a:31 ] 0, 1 or 2 keys, resolved with NO scan +MSB set → bitmap of arg indices 3+ keys, scan required +zero → no keys +``` + +- Offsets are **byte offsets of the `$` of the fragment**. No length needs storing — `$3\r\n` is + self-describing. +- 31 bits is ample (`proto-max-bulk-len` caps at 512 MB). +- Zero is a free sentinel for an empty slot: offset 0 can never be a key, because the frame starts + `*N\r\n`. +- Two slots is worth it — two-key commands are common (`SMOVE`, `RENAME`, `LMOVE`, `COPY`, + `ZRANGESTORE`, `BITOP`, `SINTERSTORE`). +- Beyond arg 63: rented `long[]`. Rare, and those are the variadic bulk commands already allocating. + +**Offsets must be buffer-absolute, not frame-relative.** `Close()` right-aligns `*N` into the reserved +prologue, so the *frame* start moves with the digit count of N. A frame-relative offset is then off by +one byte in the two-digit case — and one byte before a `$` is still inside the previous fragment's +CRLF, so you often parse *something plausible* and silently register the wrong key. Verified: with a +15-arg command (`*15`), buffer-absolute offsets still resolve correctly. + +Verified: + +``` +PASS single key, 1-digit arg count keys(no scan)=[user:1] +PASS two keys keys(no scan)=[src:set, dst:set] +PASS single key, 2-digit count (frame start moved) keys(no scan)=[user:1] + three keys -> scan required (as designed) +PASS zero keys keys(no scan)=[] +``` + +**Open seam:** on promotion to bitmap mode the two stored *offsets* must become *arg indices*, which +were never recorded, and there aren't spare bits to carry both (1 + 31 + 31 leaves one). Pragmatic +answer: re-derive by walking what's already written — rare, partial, and in L1. Needs a deliberate +decision. + +--- + +## 6. The frame as cache key + +Because the rendered bytes are the client-side cache key, **canonicality is a correctness property**, +not tidiness: two logically identical commands must render byte-identical or you get duplicate entries +and phantom misses. `CommandMap` already uppercases command names (`AsciiHash.ToUpper`, +`CommandMap.cs:270`); that has to hold for every fragment. + +`Resp.Raw` is the hole — an opaque blob bypasses every normalisation: + +```csharp +$"{cmd}{key}{"$2\r\nex\r\n"u8.Resp()}" // lowercase +$"{cmd}{key}{(RedisValue)"EX"}" // uppercase +``` + +Same command, two cache entries, forever. Silent, so this is the highest-value analyzer rule. + +Keyspace prefixes fall out correctly for free — applied before the write, so tenants cannot collide. + +### 6.1 Three incremental folds + +All O(1) state, all during the write, none needing a second pass: + +| State | Purpose | +| --- | --- | +| `int _slot` | routing | +| `ulong _keyMarks` | key resolution for invalidation | +| rolling hash | cache probe | + +A lookup is then hash → bucket → `SequenceEqual`, with no walk unless a real collision. + +### 6.2 Buffer ownership + +A pooled buffer **must not** be retained as a dictionary key without ownership transfer — `ArrayPool` +reuse would mutate live cache keys, and the failure mode is wrong data served from cache, not a crash. + +Resolution: the cache entry **pins the lease** for its lifetime; the buffer is returned on eviction. + +Two requirements: + +- **Dispose must be neuterable.** The transfer decision comes late (only after dispatch do you know + whether the response is cacheable), so the consumer's `using` is correct on every path *except* the + one where ownership moved. Precedent exists: `MemoryTrackedPool.MemoryManager.Dispose` does + `Interlocked.Exchange(ref array, null)` and only returns if it won (`MemoryTrackedPool.cs:58`). + `TransferOwnership()` wins that exchange first. +- **Accept permanent rounding slack.** A pooled lease is power-of-two sized, so a 130-byte frame pins + a 256-byte array for the entry's life — roughly a third overhead on retained bytes. Minor, and a + custom chunk pool with buckets fitted to the real frame distribution would tighten it. + +Pinning also **keeps the key offsets valid**: buffer-absolute offsets stay resolvable for the entry's +whole lifetime, so keys can be recovered lazily from a cached entry without re-rendering. Copying +would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, +reintroduced at a second site. + +### 6.3 Exception paths + +The lowering puts construction and all `Append` calls in the *caller's* frame, before `Execute` is +entered: + +```csharp +var h = new Resp(...); // rents here +h.AppendFormatted(cmd); // can throw: disabled command +h.AppendFormatted(key); // can throw: a hole's property getter +Execute(h, handler); // consumer's using/try-finally only starts HERE +``` + +So on a throw in that window there is no handler for the consumer to dispose. Dropping the buffer is +the only available behaviour, and it is fine: `MemoryTrackedPool` is a thin wrapper over +`ArrayPool.Shared` (`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, +so a dropped buffer is simply garbage. + +Two notes: + +- **Validate the command before renting.** A CommandMap-disabled command is both the most likely throw + here and the most likely to *repeat* (config-driven, so every call). In the argument form `command` + reaches the constructor, so it can be checked before the rent. +- **A bounded custom pool would invalidate this.** Dropping into `ArrayPool.Shared` is free because + Shared doesn't track; dropping a chunk from a bounded free-list permanently removes capacity and + silently degrades to allocating every time. `CycleBuffer.AppendOrRecycle(segment, maxDepth: 2)` shows + the bounded pattern is idiomatic here, so this needs care. Keep any dedicated pool unbounded — + allocate on miss, return opportunistically. + +--- + +## 7. Analyzer rules + +The analyzer **does** reach consumers: `StackExchange.Redis.csproj:83-100` packs both +`eng/StackExchange.Redis.Build` and `eng/StackExchange.Redis.CodeFixes` into `analyzers/dotnet/cs`, +with hard errors at pack time if either is missing. (AGENTS.md describes that project as "Not shipped", +which is now inaccurate.) + +**`.Resp()` is permitted only on literals.** That makes every rule below decidable, and it does not +break the static-property reuse pattern — `.Resp()` is still on a literal at the declaration site, and +the call site is a plain property read. + +For this to be enforced rather than advisory, `Raw`'s constructor must be `internal` with `.Resp()` as +the only public factory. Internal construction stays available for `CommandMap.GetResp` and +per-connection preambles. + +Rules: + +1. **Raw framing** — `$`, digits, CRLF, payload of exactly that length, CRLF; repeated exactly + `ArgCount` times, consuming the whole blob. +2. **Canonical casing** — token payloads uppercase. The cache-key correctness rule; the only one whose + violation is silent. +3. **Receiver is `u8`, not `string`** — otherwise a runtime encode was silently paid. +4. **No raw string literals for RESP blobs** — `.gitattributes` is `* text=auto`, so a `"""..."""` + blob bakes in platform-native line endings (LF on Linux, CRLF on Windows). Require `\r\n` escapes. +5. **No keys inside a `Raw`** — invisible to the handler, so they would never be registered for + invalidation or counted for routing. +6. **Shape** — at least one hole; first hole is a `RedisCommand`. +7. Possibly: a better diagnostic than `CS1503` for an unsupported hole type. + +Rules 1–3 have mechanical code fixes, which is presumably what the CodeFixes assembly is for. + +--- + +## 8. Open questions + +- **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit + cursor by its arg count, not by 1. Either forbid keys in `Raw` (rule 5) or have `Raw` carry its own + bitmap to shift and OR in. +- **Promotion seam** (§5.2) — re-derive arg indices by walking, or something better. +- **Single-arg vs multi-arg `Raw`.** Restricting `Raw` to exactly one bulk string keeps `*N` a + compile-time constant; allowing multi-arg costs runtime counting. Possibly two types. +- **A runtime-validating `Raw` factory** for fragments assembled once at startup from config — the one + legitimate case the literal-only rule closes off. +- **Public API commitment.** A public method taking the handler forces the handler type public, putting + every `AppendFormatted` overload into `PublicAPI.Unshipped.txt` permanently. Can the interpolated + surface start internal (RESPite-side, used by SE.Redis) to buy room to iterate? +- **Should the handler be a `ref struct`?** It holds only a `byte[]`. Ref struct prevents capture, + copying and double-dispose, which is why it is right — but it also blocks `using var` + `ref` (§4) + and any async retention. +- **Static key bitmaps.** For fixed-arity commands the key positions are statically known, so the JIT + may constant-fold the bitmap when the Append chain inlines. Not to be designed around, but the + structure permits it and an analyzer could emit the constant if it matters. + +--- + +## 9. Verification log + +Everything above marked "verified" was compiled and, where runtime behaviour was in question, run. +Scratch projects multi-target `netstandard2.0;net472;net8.0`, `LangVersion 14`, referencing the real +`src/RESPite` and `src/StackExchange.Redis`. + +Generated frames were validated by parsing them back with RESPite's own `RespReader`, including +`DemandEnd()` so the frame must be exactly consumed — over- and under-run both fail. + +Hash slots were checked against published `CLUSTER KEYSLOT` values: `foo`→12182, `bar`→5061, +`hello`→866, `somekey`→11058, `""`→0, all passing, with hash-tag handling matching +`ServerSelectionStrategy.GetClusterSlot` (first `{`, first `}` after it, non-empty between). + +**Not verified:** nothing was run against a live server — these are local bytes only, which is the +right contract given where this sits, but it means only *framing* is proven, not that any particular +argument combination is semantically acceptable to a server. `net461` was not compiled. +Micro-benchmark numbers in §5.2 are indicative order-of-magnitude only, not a controlled benchmark. From 281b7fc0eb59ceda327a4d45cece56e3170dfd9d Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 14:46:20 +0100 Subject: [PATCH 002/360] Design notes: receiver capture, a context object, and Index/Range Follow-up findings, all verified cross-assembly (library + consumer projects): - [InterpolatedStringHandlerArgument("")] passes the call's receiver into the handler ctor, and works for a concrete receiver, via an interface, implicit 'this', an object-typed ctor param, and extension methods (nameof(param)). The receiver's static type at the call site must convert to the ctor param type - so declaring Execute on IDatabase forces the ctor to take IDatabase. - That rules out reaching CommandMap: it is not on IConnectionMultiplexer and is not public API. Downcasting would break every IDatabase mock, during command construction, before the mock's Execute is reached. - Resolution is a dedicated context type as the receiver, carrying CommandMap, KeyPrefix, buffer manager, client-side cache and ServerType. One per (multiplexer, db, prefix); a class, not a struct. - Cost: that type must be public, because the consumer's own lowered code calls the handler ctor - an internal ctor fails at the call site with CS1729. Its members can all be internal, which keeps the commitment to a single sealed public type consumers can neither construct nor read. - Index/Range polyfill down-level but only as internal types, so they cannot appear in public API; the key-resolution surface needs a purpose-built (offset, length) struct instead. --- design/interpolated-resp-writer.md | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 8927ad232..9d7af1458 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -39,6 +39,7 @@ Verified by compiling across `netstandard2.0` / `net472` / `net8.0`: | C# 14 extension members (`extension(...) { }`) | works | also pure lowering | | `[OverloadResolutionPriority]` | works | polyfill the attribute in source | | `scoped` on span params | works | `ScopedRefAttribute` is compiler-synthesized | +| `System.Index` / `System.Range` | works, but **`internal` only** | public polyfill would collide on newer TFMs; unusable in public API | `net461` was not tested (no reference assemblies to hand) but uses the same compiler path; the only dependency is `ReadOnlySpan`, which RESPite already has there via `System.Memory`. @@ -189,6 +190,50 @@ loses the receiver, which means: Prefer the argument form. Its terseness advantage is small and the receiver is the thing you need. +### 3.3 The receiver, and a context object + +`[InterpolatedStringHandlerArgument("")]` passes the **receiver** of the call into the handler's +constructor. Verified working in every shape that matters — concrete receiver, receiver via an +interface, implicit `this` from inside the type, an `object`-typed ctor parameter, and extension +methods (where the receiver is the first parameter, so `nameof(db)` rather than `""`). + +**Rule:** the receiver's *static type at the call site* must be convertible to the ctor parameter +type. Declaring `Execute` on `IDatabase` therefore forces the ctor to accept `IDatabase`. + +That is a problem, because `CommandMap` is not reachable from there — it is not on +`IConnectionMultiplexer` and is not public API at all; `IDatabase` reaches only +`IConnectionMultiplexer Multiplexer` (`IRedisAsync.cs:14`). A downcast would work inside the +assembly but **breaks every `IDatabase` mock**, and breaks it during command *construction*, in the +caller's frame, before the mock's `Execute` is reached. + +**Resolution: a dedicated context type as the receiver** — `ctx.Execute($"...")` — carrying: + +| Shared per multiplexer | Varies per instance | +| --- | --- | +| CommandMap, buffer manager, client-side cache, `ServerType` | `KeyPrefix`, database index | + +`ServerType` matters: `HashSlot` short-circuits to `NoSlot` for standalone +(`ServerSelectionStrategy.cs:101-102`), so without it the handler computes CRC16 over every key for +standalone deployments that never use the result. + +That granularity is one context per *(multiplexer, db, prefix)* — what `RedisDatabase` already has — +so cache one per database instance rather than allocating per command. Make it a **class**: a struct +with five or six fields is copied into the handler on every command, a reference is one field. + +**Cost: the context type must be public.** The accessibility chain is forced, and verified +cross-assembly — an `internal` handler constructor fails at the consumer call site with +`CS1729: does not contain a constructor that takes 3 arguments`, because it is the *consumer's* +lowered code that constructs the handler. Public ctor therefore implies a public parameter type. + +Its **members can all be internal**, though: a `public sealed class` whose `CommandMap`/`KeyPrefix`/ +`BufferManager` are internal works cross-assembly and gives consumers a name they can neither +construct nor read from. Verified. That is a small commitment, but a permanent one, so it belongs in +`PublicAPI.Shipped.txt` deliberately rather than being noticed at pack time. + +If the context is unavailable for some path, command resolution can be **deferred** instead — the +handler stores the `RedisCommand` and the concrete implementation resolves it at `Close`/`Execute`. +That is the same mechanism §3.2 already needs, and it keeps mocks working. + --- ## 4. Deferred composition @@ -289,6 +334,11 @@ PASS single key, 2-digit count (frame start moved) keys(no scan)=[user:1] PASS zero keys keys(no scan)=[] ``` +**Do not expose `System.Range` in the key-resolution API.** `Index`/`Range` polyfill fine down-level +(the compiler matches them by name), but the polyfill must be `internal` — public would collide with +the real types on newer TFMs. A public method taking `Span` then fails with +`CS0051: Inconsistent accessibility`. Use a purpose-built `(offset, length)` struct. + **Open seam:** on promotion to bitmap mode the two stored *offsets* must become *arg indices*, which were never recorded, and there aren't spare bits to carry both (1 + 31 + 31 leaves one). Pragmatic answer: re-derive by walking what's already written — rare, partial, and in L1. Needs a deliberate From ddafe50b46df3a973adc03fa1bd30c765fa7b561 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 14:52:28 +0100 Subject: [PATCH 003/360] Design notes: relate to the marc/respite v3 PoC spike Records the overlap with origin/marc/respite (tip, 117 commits ahead of main, last touched 2026-03-06). Note marc/respite-weekend is a sub-branch that merged into it, not the spike itself. The framing: this replaces the spike's WRITER half only - the manual RespWriter plus RespOperationBuilder. The execution API around it carries over unchanged. What carries over: RespContext as a four-field readonly struct with With* clone methods, the Lifetime/linked-CTS cancellation handling, RespContextFlags being bit-aligned with CommandFlags so no mapping table is needed, and RespContextDatabase implementing IDatabase over IRespContextSource. That is the same context object section 3.3 arrived at independently, but with a better factoring: it derives CommandMap from the connection instead of storing it, which keeps it to four fields and makes the With* clone pattern affordable. Section 3.3's "make it a class" advice is amended to defer to this. What the handler replaces, and why it is better: - WriteCommand(command, args) takes a hand-maintained argument count, which can disagree with the writes that follow - exactly what RenderedArgs.ThrowArgCountMismatch exists to catch at runtime on main. With the handler, formattedCount is a compile-time constant and the mismatch is unrepresentable. - WriteKey(...) is a plain alias for WriteBulkString with no behaviour; that is where prefix application, the slot fold and the key marks have to attach. - CommandMap is a settable mutable property on the writer rather than something it is constructed with. Also records the key-prefix gap: the spike has RespConfiguration.KeyPrefix and distinct WriteKey overloads, but nothing connects them and KeyPrefixed*.cs is still present. So the KeyPrefixedDatabase replacement is the pattern - a cheap value-type context with With* clones instead of a decorator per prefix - rather than something finished. Wiring it is part of this work. --- design/interpolated-resp-writer.md | 91 ++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 9d7af1458..57f553496 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -14,6 +14,10 @@ building a `Message` + argument array. The handler is pure formatting: it runs o thread, ahead of the critical section, and the bytes it produces double as the client-side cache key before anything touches the muxer core. It is never near a connection. +This is intended to **replace the writer half of the `marc/respite` v3 PoC spike** — its manual +`RespWriter` + `RespOperationBuilder`. The execution API around it (`RespContext`, cancellation, +`RespContextDatabase`) is good and carries over unchanged. See §8. + --- ## 1. Is the handler pattern usable down-level? @@ -217,8 +221,12 @@ caller's frame, before the mock's `Execute` is reached. standalone deployments that never use the result. That granularity is one context per *(multiplexer, db, prefix)* — what `RedisDatabase` already has — -so cache one per database instance rather than allocating per command. Make it a **class**: a struct -with five or six fields is copied into the handler on every command, a reference is one field. +so cache one per database instance rather than allocating per command. + +**Class or struct:** a context that *stores* all of the above wants to be a class, since a five- or +six-field struct is copied into the handler on every command. But it does not have to store them — +see §8, where the `marc/respite` spike keeps `RespContext` to four fields and derives `CommandMap` +through the connection. That factoring is better and keeps a `readonly struct` viable. **Cost: the context type must be public.** The accessibility chain is forced, and verified cross-assembly — an `internal` handler constructor fails at the consumer call site with @@ -462,7 +470,82 @@ Rules 1–3 have mechanical code fixes, which is presumably what the CodeFixes a --- -## 8. Open questions +## 8. Prior art: the `marc/respite` spike + +`origin/marc/respite` is the v3 PoC spike — a tip (nothing contains it), 117 commits ahead of `main`, +370 files under `src/`, last commit `2026-03-06` *"support WriteMode"*. Siblings from the same push: +`marc/localwriter` (2026-03-06) and `marc/resp-reader` (2026-03-13). `marc/respite-weekend` +(2025-09-22) is a sub-branch that merged into it, not the spike itself. + +Projects: `RESP.Core`, `RESPite`, `RESPite.Redis`, `RESPite.StackExchange.Redis`, +`RESPite.Benchmark`, alongside `StackExchange.Redis`. + +**This document replaces the spike's *writer* half only.** The execution API around it is good and +carries over. + +### 8.1 What carries over + +`RespContext` — a `readonly struct` of four fields: + +```csharp +private readonly RespConnection _connection; +public readonly CancellationToken CancellationToken; +private readonly int _database; +private readonly RespContextFlags _flags; + +public RespCommandMap CommandMap => _connection.NonDefaultCommandMap ?? RespCommandMap.Default; +``` + +- `With*` clone surface — `WithCancellationToken`, `WithDatabase`, `WithConnection`, `WithFlags`, + `With(db, flags)`, `With(db, flags, mask)`, `ConfigureAwait` — each copying the struct and assigning + through `Unsafe.AsRef(in clone._x)`. +- Cancellation: `WithCombineCancellationToken` / `WithCombineTimeout` / `WithCombine` return a + `Lifetime : IDisposable` owning the linked CTS, with the no-op fast paths handled (uncancellable + token, already-equal token, no existing token to link). +- `RespContextFlags` is bit-aligned with `CommandFlags`, so `RespContextDatabase.Context(flags)` is a + cast plus a mask — no mapping table. +- `RespContextDatabase` implements `IDatabase` over an `IRespContextSource`, split across the usual + per-type partials. + +**This is the context object §3.3 arrives at independently**, and its factoring is better than what +§3.3 first proposed: it *derives* `CommandMap` from the connection rather than storing it, which is +what keeps it to four fields and makes the `With*` clone pattern affordable. Adopt that — store the +connection, derive the rest — rather than a class holding CommandMap + prefix + buffer manager + +cache + `ServerType`. + +### 8.2 What the handler replaces + +The spike's writer is a manual builder: `RespOperationBuilder` from +`RespContextExtensions.Command()`, then `Send`/`SendAsync`/`CreateOperation`, with the frame +assembled by hand through `RespWriter`. Three specific things get better: + +| Spike | Handler | +| --- | --- | +| `WriteCommand(command, args)` — caller states the arg count | `formattedCount` is a compile-time constant | +| `WriteKey(...)` — a plain alias for `WriteBulkString`, no behaviour | `AppendFormatted(RedisKey)` — prefix, slot fold, key marks | +| `public RespCommandMap? CommandMap { get; set; }` on the writer | supplied via the receiver; immutable, cannot be forgotten | + +The first is the substantive one. A hand-maintained argument count can disagree with the writes that +follow it, which is exactly the failure `RenderedArgs.ThrowArgCountMismatch` exists to catch on `main` +— a runtime check for something the handler makes unrepresentable. + +The second matters because `WriteKey` is where routing and invalidation have to attach. The spike cut +the seam in the right place and left it empty. + +### 8.3 The key-prefix gap + +The spike has `RespConfiguration.KeyPrefix` (`ReadOnlySpan`, settable as `string` or `byte[]` +on the builder) *and* distinct `WriteKey` overloads on `RespWriter` — but nothing connects them; the +prefix is never applied, and `KeyspaceIsolation/KeyPrefixed*.cs` is still present on the branch. + +So "the execution API that replaces `KeyPrefixedDatabase`" is the *pattern* — a cheap value-type +context threaded through with `With*` clones, instead of a decorator object per prefix — rather than +something finished. Wiring it is part of this work: `AppendFormatted(RedisKey)` applies the prefix +before both the write and the slot (§5.1). + +--- + +## 9. Open questions - **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit cursor by its arg count, not by 1. Either forbid keys in `Raw` (rule 5) or have `Raw` carry its own @@ -484,7 +567,7 @@ Rules 1–3 have mechanical code fixes, which is presumably what the CodeFixes a --- -## 9. Verification log +## 10. Verification log Everything above marked "verified" was compiled and, where runtime behaviour was in question, run. Scratch projects multi-target `netstandard2.0;net472;net8.0`, `LangVersion 14`, referencing the real From 82a19974c25de10ab0ecf7c9a7730c7240552163 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 15:41:15 +0100 Subject: [PATCH 004/360] Add a working interpolated-string RESP writer spike, with tests All internal, so there is no public API commitment; 28 unit tests, green on net10.0 and net8.0, net481 compiles, and Release /p:CI=true /p:RunAnalyzers=true is clean. - FrameworkShims.InterpolatedStringHandler.cs: the attribute polyfill, so the handler works on net461/netstandard2.0/net472 with no runtime support - same trick as the IsExternalInit shim. - Interpolated/RespContext.cs: CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType and CancellationToken, with With* clones and an Execute that takes the handler via [InterpolatedStringHandlerArgument("")] - ie the receiver. - Interpolated/RespCommandHandler.cs: renders the frame, folds the cluster slot over the bytes it just wrote, and marks which arguments were keys. - Interpolated/RespFrame.cs: the rendered frame plus slot and key marks; two 31-bit buffer-absolute offsets inline, falling back to a scan beyond that. Notes from building it: - A struct context must be valid in its default state: new RespContext() binds the struct's implicit parameterless constructor rather than the all-optional one, so CommandMap was null and every test threw. It now falls back to CommandMap.Default on read. - Supporting both key-prefix mechanisms costs nothing here. RedisKey.WithPrefix allocates to combine two prefixes only because it must return a RedisKey; the writer needs the bytes, and TotalLength()/CopyTo() already include the key's own prefix - so writing the context prefix ahead of them composes both for free. Verified at zero bytes over 128 renders. - Both mechanisms render byte-identically, which is what allows the frame to serve as a cache key; pinned by a test, since RedisKey.Equals does NOT treat them as equal. Design doc gains section 8.4 on the prefix decision and on the read half - which today is largely unhandled: RANDOMKEY throws, script/Execute results keep their prefixes behind seven TODOs, the Resp APIs opt out deliberately, and multi-key pop results forward unstripped. Records why the decorator cannot fix that and a context can, and the constraint that stripping belongs at the API boundary and never in the cache or routing layer. --- design/interpolated-resp-writer.md | 59 +++- ...rameworkShims.InterpolatedStringHandler.cs | 23 ++ .../Interpolated/RespCommandHandler.cs | 212 +++++++++++ .../Interpolated/RespContext.cs | 118 +++++++ .../Interpolated/RespFrame.cs | 112 ++++++ .../InterpolatedWriterUnitTests.cs | 330 ++++++++++++++++++ 6 files changed, 852 insertions(+), 2 deletions(-) create mode 100644 src/StackExchange.Redis/FrameworkShims.InterpolatedStringHandler.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespCommandHandler.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespContext.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespFrame.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 57f553496..ce5849fd7 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1,7 +1,9 @@ # Interpolated-string RESP writer -Design notes for the v3 writer work. **Nothing here is implemented** — this records what was -verified empirically, what follows from it, and what is still open. +Design notes for the v3 writer work. This records what was verified empirically, what follows from it, +and what is still open. A working spike lives in `src/StackExchange.Redis/Interpolated/` with unit tests +in `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs`; everything there is `internal`, so +there is no public API commitment yet. The idea: let command construction read as @@ -545,6 +547,59 @@ before both the write and the slot (§5.1). --- +## 8.4 Key prefixes: both mechanisms, permanently + +The two prefix mechanisms coexist for good — the context's prefix, and the prefix a `RedisKey` already +carries from a `KeyPrefixed*` decorator. That is awkward conceptually but free in the writer: +`RedisKey.WithPrefix` only allocates in its *"two prefixes; darn"* branch because it must hand back a +`RedisKey`; the handler needs only the combined *bytes*, and `TotalLength()`/`CopyTo()` already include +the key's own prefix. So writing the context prefix immediately ahead of them composes both with no +intermediate object. Verified: **0 bytes allocated over 128 renders** with both prefixes in play. + +The context normalises its key prefix to bytes once at construction, so a string-backed prefix does not +re-convert per command. `WithKeyPrefix` still composes eagerly via `WithPrefix`, but that is once per +context clone, not per command. + +The two mechanisms stay distinguishable as *values* (`RedisKey.Equals` compares the carried prefix) but +render byte-identically — pinned by `BothPrefixMechanismsRenderIdenticalBytes`. That is what lets the +rendered frame serve as a cache key (§6): cache identity must come off the frame, never off the key +object. + +### The new `KeyPrefixedDatabase` + +The write half collapses to `localCtx = downstreamCtx.WithKeyPrefix(prefix)` with no per-method +overrides — roughly 2600 lines of forwarding in `KeyspaceIsolation/` become one context clone. + +**The read half is the open part**, and today it is largely unhandled rather than merely imperfect: + +- `KeyRandom`/`KeyRandomAsync` **throw** `NotSupportedException`, documented in the `WithKeyPrefix` + remarks (`DatabaseExtension.cs`). +- Script and `Execute` results carry prefixed keys — seven `// TODO: ... might make sense to 'unprefix'` + sites, and the public docs state the caveat. +- RESP APIs opt out deliberately: `// note the Resp API explicitly doesn't unprefix keys`. +- Multi-key pop results (`ListPopResult` from `ListLeftPopAsync(RedisKey[], long)`) forward unstripped, + not even TODO'd. + +The decorator *cannot* fix this: it sits above the database with no hook into result processing, so +stripping would mean wrapping every return value. A prefix on the context, threaded through to the +result processor, makes it one concern in one place. `ChannelPrefix` already has exactly this shape on +the read side (`PhysicalConnection.Read.cs:817`); keys never got the equivalent. + +Two constraints on doing it: + +- **Strip at the API boundary, never in the cache or routing layer.** Invalidation pushes carry key + names, and the cache is keyed on rendered frames, which contain *prefixed* keys — so an invalidation + in prefixed form matches as-is. Stripping earlier would silently stop invalidations matching: stale + reads, no error. +- **Stripping must be conditional.** A script can return a key it built itself that never carried the + prefix, so it has to be "starts with the prefix → strip, else leave", and documented as such. + +Commands that return key names, and so need this: `RANDOMKEY`, `KEYS`, `SCAN`, the blocking and multi +pops (`BLPOP`/`BRPOP`/`LMPOP`/`ZMPOP`/`BZPOPMIN`/`BZPOPMAX`), `XREAD`/`XREADGROUP` stream names, +keyspace notifications, and script/`Execute` results. + +--- + ## 9. Open questions - **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit diff --git a/src/StackExchange.Redis/FrameworkShims.InterpolatedStringHandler.cs b/src/StackExchange.Redis/FrameworkShims.InterpolatedStringHandler.cs new file mode 100644 index 000000000..ec7d70532 --- /dev/null +++ b/src/StackExchange.Redis/FrameworkShims.InterpolatedStringHandler.cs @@ -0,0 +1,23 @@ +#if !NET6_0_OR_GREATER +// Interpolated string handlers are entirely a compiler feature - the attributes are matched by name, +// not identity, so declaring them here enables $"..." handlers on down-level targets with no runtime +// support of any kind. Same trick as FrameworkShims.IsExternalInit.cs. +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] + internal sealed class InterpolatedStringHandlerAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class InterpolatedStringHandlerArgumentAttribute : Attribute + { + public InterpolatedStringHandlerArgumentAttribute(string argument) => Arguments = new[] { argument }; + + public InterpolatedStringHandlerArgumentAttribute(params string[] arguments) => Arguments = arguments; + + public string[] Arguments { get; } + } +} +#endif diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs new file mode 100644 index 000000000..c10a16cf8 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -0,0 +1,212 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Renders a RESP command from an interpolated string, folding the cluster slot and + /// marking which arguments were keys as it writes. + /// + /// + /// Every part must be a hole - $"{RedisCommand.SET}{key}{value}" - because that makes the + /// compiler-supplied formattedCount the argument count. See design/interpolated-resp-writer.md. + /// + [InterpolatedStringHandler] + internal ref struct RespCommandHandler + { + /// '*' plus up to nine digits plus CRLF; reserved at the front so the header can be + /// back-filled right-aligned once the final argument count is known. + private const int HeaderMax = 12; + + private readonly RespContext _context; + private byte[] _buffer; + private int _offset; // absolute write cursor within _buffer + private int _args; // RESP argument count, including the command + private int _argIndex; // logical argument position, for the overflow bitmap + private int _slot; + private ulong _keyMarks; + private bool _hasCommand; + + public RespCommandHandler(int literalLength, int formattedCount, RespContext context) + { + _context = context; + _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + literalLength + (formattedCount * 24)); + _offset = HeaderMax; + _args = 0; + _argIndex = 0; + _slot = ServerSelectionStrategy.NoSlot; + _keyMarks = 0; + _hasCommand = false; + } + + [Obsolete("Every part must be a hole, so that the argument count is known at compile time; write $\"{RedisCommand.SET}{key}{value}\", not $\"SET{key}{value}\".", error: true)] + public void AppendLiteral(string value) => throw new NotSupportedException(); + + public void AppendFormatted(RedisCommand value) + { + if (_hasCommand) throw new InvalidOperationException("The command must be the first argument, and may only be given once."); + + var resp = _context.CommandMap.GetResp(value); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(value); + + Ensure(resp.Length); + resp.CopyTo(_buffer.AsSpan(_offset)); + _offset += resp.Length; + _hasCommand = true; + _args++; + _argIndex++; + } + + public void AppendFormatted(RedisKey value) + { + DemandCommand(); + + // Both prefix mechanisms have to coexist: the context's prefix, and any prefix the key already + // carries from a KeyPrefixed* decorator. RedisKey.WithPrefix would ALLOCATE to combine them (see + // its "two prefixes; darn" branch) because it has to hand back a RedisKey - but we only need the + // combined BYTES, and we are writing bytes anyway. TotalLength()/CopyTo() already account for the + // key's own prefix, so writing the context prefix ahead of it composes both for free. + var prefix = _context.KeyPrefixSpan; + MarkKey(_offset); + var keyLength = value.TotalLength(); + var length = prefix.Length + keyLength; + var payload = WriteBulk(length, out var payloadOffset); + prefix.CopyTo(payload); + var written = value.CopyTo(payload.Slice(prefix.Length)); + Debug.Assert(written == keyLength, "key length disagreed with itself"); + CommitBulk(payloadOffset, length); + FoldSlot(payload); + _args++; + _argIndex++; + } + + public void AppendFormatted(RedisChannel value) + { + DemandCommand(); + + // unlike keys, the channel prefix is writer state and is conditional on the channel itself - + // keyspace notification channels are server-generated names and opt out + ReadOnlySpan prefix = value.IgnoreChannelPrefix ? default : (byte[]?)_context.ChannelPrefix; + ReadOnlySpan body = (byte[]?)value; + + var length = prefix.Length + body.Length; + var payload = WriteBulk(length, out var payloadOffset); + prefix.CopyTo(payload); + body.CopyTo(payload.Slice(prefix.Length)); + CommitBulk(payloadOffset, length); + FoldSlot(payload); + _args++; + _argIndex++; + } + + public void AppendFormatted(RedisValue value) + { + DemandCommand(); + + var length = value.GetByteCount(); + var payload = WriteBulk(length, out var payloadOffset); + var written = value.CopyTo(payload); + Debug.Assert(written == length, "value length disagreed with itself"); + CommitBulk(payloadOffset, length); + _args++; + _argIndex++; + } + + /// + /// Back-fill the *N header into the reserved prologue, right-aligned, and take ownership of + /// the buffer away from the handler. + /// + public RespFrame Complete() + { + if (!_hasCommand) throw new InvalidOperationException("No command was written."); + + Span header = stackalloc byte[HeaderMax]; + header[0] = (byte)'*'; + var headerLength = MessageWriter.WriteRaw(header, _args, offset: 1); + var start = HeaderMax - headerLength; + header.Slice(0, headerLength).CopyTo(_buffer.AsSpan(start)); + + var frame = new RespFrame(_buffer, start, _offset - start, _args, _slot, _keyMarks); + _buffer = null!; // ownership transferred to the frame + return frame; + } + + public void Dispose() + { + var buffer = _buffer; + _buffer = null!; + if (buffer is not null) ArrayPool.Shared.Return(buffer); + } + + private void DemandCommand() + { + if (!_hasCommand) throw new InvalidOperationException("The first argument must be a RedisCommand."); + } + + /// Write '$len\r\n' and return the span the payload should be written into. + private Span WriteBulk(int payloadLength, out int payloadOffset) + { + Ensure(payloadLength + HeaderMax + 2); + var span = _buffer.AsSpan(_offset); + span[0] = (byte)'$'; + payloadOffset = MessageWriter.WriteRaw(span, payloadLength, offset: 1); + return span.Slice(payloadOffset, payloadLength); + } + + /// Terminate the fragment begun by and advance the cursor. + private void CommitBulk(int payloadOffset, int payloadLength) + { + MessageWriter.WriteCrlf(_buffer.AsSpan(_offset), payloadOffset + payloadLength); + _offset += payloadOffset + payloadLength + 2; + } + + /// + /// Routing needs only O(1) state, and the bytes have just been written - so fold over those rather + /// than re-materialising the key, which is what has + /// to do. + /// + private void FoldSlot(scoped ReadOnlySpan payload) + { + if (_context.ServerType != ServerType.Cluster) return; + _slot = ServerSelectionStrategy.CombineSlot(_slot, ServerSelectionStrategy.GetClusterSlot(payload)); + } + + /// + /// Record that a key starts at this BUFFER-ABSOLUTE offset. Absolute matters: + /// right-aligns the header, so the FRAME start moves with the digit count of the argument count. + /// + private void MarkKey(int offset) + { + if ((_keyMarks & RespFrame.OverflowFlag) != 0) + { + if (_argIndex < 63) _keyMarks |= 1UL << _argIndex; + return; + } + + if ((_keyMarks & RespFrame.SlotMask) == 0) + { + _keyMarks |= (ulong)offset & RespFrame.SlotMask; + } + else if (((_keyMarks >> RespFrame.SlotBits) & RespFrame.SlotMask) == 0) + { + _keyMarks |= ((ulong)offset & RespFrame.SlotMask) << RespFrame.SlotBits; + } + else + { + // a third key: the inline offsets cannot express it, so fall back to a walk + _keyMarks = RespFrame.OverflowFlag; + } + } + + private void Ensure(int extra) + { + if (_buffer.Length - _offset >= extra) return; + var bigger = ArrayPool.Shared.Rent(Math.Max(_buffer.Length * 2, _offset + extra)); + Buffer.BlockCopy(_buffer, 0, bigger, 0, _offset); + ArrayPool.Shared.Return(_buffer); + _buffer = bigger; + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs new file mode 100644 index 000000000..283029e08 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -0,0 +1,118 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Transient state for a RESP operation: everything the writer needs in order to + /// render a command. Supplied to an interpolated string handler as the receiver of the call. + /// + /// + /// See design/interpolated-resp-writer.md. + /// + /// This is the same triple already carries (command map, channel prefix, key + /// prefix) plus the cancellation/routing state from the marc/respite spike's RespContext. + /// Long term this replaces , whose constructor already takes + /// (channel prefix, command map, target). + /// + /// + /// Note the two prefixes reach the wire by different routes today: the channel prefix is writer state + /// applied at write time, whereas the key prefix rides on the itself, put there + /// upstream by the KeyPrefixed* decorators. Applying BOTH here at write time is what makes those + /// decorators unnecessary - but note a key may ALREADY carry its own prefix, so the two must compose + /// (via ) rather than conflict. + /// + /// + internal readonly struct RespContext + { + public RespContext( + CommandMap? commandMap = null, + RedisKey keyPrefix = default, + RedisChannel channelPrefix = default, + int database = 0, + ServerType serverType = ServerType.Standalone, + CancellationToken cancellationToken = default) + { + _commandMap = commandMap; + _keyPrefix = keyPrefix; // normalise to bytes ONCE; the conversion can allocate for a string-backed key + ChannelPrefix = channelPrefix; + Database = database; + ServerType = serverType; + CancellationToken = cancellationToken; + } + + private readonly CommandMap? _commandMap; + + /// + /// The command map. Note this copes with default(RespContext): a struct always has an implicit + /// parameterless constructor that zeroes every field, and new RespContext() binds to THAT rather + /// than to the all-optional-arguments constructor below - so no field may be assumed non-null. + /// + public CommandMap CommandMap => _commandMap ?? CommandMap.Default; + + private readonly byte[]? _keyPrefix; + + /// The key prefix applied to every written through this context. + public RedisKey KeyPrefix => _keyPrefix; + + /// The key prefix as raw bytes, so the writer never pays a conversion per command. + internal ReadOnlySpan KeyPrefixSpan => _keyPrefix; + + public RedisChannel ChannelPrefix { get; } + + public int Database { get; } + + public ServerType ServerType { get; } + + public CancellationToken CancellationToken { get; } + + public RespContext WithCancellationToken(CancellationToken cancellationToken) + => new(CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, cancellationToken); + + public RespContext WithDatabase(int database) + => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken); + + public RespContext WithServerType(ServerType serverType) + => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken); + + /// + /// Returns a context whose keys are prefixed. This is what replaces wrapping the database in a + /// KeyPrefixedDatabase decorator: the prefix is state on a value type, not a new object graph. + /// Nested calls compose, matching the decorator's behaviour. + /// + public RespContext WithKeyPrefix(RedisKey keyPrefix) + => new( + CommandMap, + _keyPrefix is null ? keyPrefix : RedisKey.WithPrefix(_keyPrefix, keyPrefix), + ChannelPrefix, + Database, + ServerType, + CancellationToken); + + public RespContext WithChannelPrefix(RedisChannel channelPrefix) + => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken); + + /// + /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into the + /// handler's constructor; that is how the handler reaches the command map, the prefixes, and the + /// server type. + /// + /// + /// A real Execute would go on to dispatch the frame; this spike stops at "the right bytes were + /// rendered, and we know which arguments were keys". + /// + public RespFrame Execute([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) + { + if (CancellationToken.IsCancellationRequested) + { + // the handler has already rented a buffer by the time we get here - it is built in the + // CALLER's frame, before this method is entered - so cancelling has to hand it back + handler.Dispose(); + CancellationToken.ThrowIfCancellationRequested(); + } + + return handler.Complete(); + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs new file mode 100644 index 000000000..16fb7cf3e --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -0,0 +1,112 @@ +using System; +using System.Buffers; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A rendered RESP frame, plus the routing and invalidation metadata that was + /// folded while it was being written. + /// + internal struct RespFrame : IDisposable + { + // key marks: MSB clear => up to two 31-bit BUFFER-ABSOLUTE byte offsets, resolvable with no scan; + // MSB set => the frame must be walked to recover keys. Zero means "no keys" - offset 0 can never be + // a key, because the frame starts '*N\r\n'. + internal const ulong OverflowFlag = 1UL << 63; + internal const int SlotBits = 31; + internal const ulong SlotMask = (1UL << SlotBits) - 1; + + private byte[]? _buffer; + private readonly int _start; + private readonly int _length; + private readonly ulong _keyMarks; + + internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, ulong keyMarks) + { + _buffer = buffer; + _start = start; + _length = length; + _keyMarks = keyMarks; + ArgCount = argCount; + Slot = slot; + } + + /// The number of RESP arguments, including the command itself. + public int ArgCount { get; } + + /// The combined cluster slot, or /. + public int Slot { get; } + + /// The rendered frame. + public readonly ReadOnlySpan Span => new(_buffer, _start, _length); + + /// True when there were more keys than the inline marks can hold, so recovering them needs a walk. + public readonly bool KeysNeedScan => (_keyMarks & OverflowFlag) != 0; + + /// True when no argument was a key. + public readonly bool HasNoKeys => _keyMarks == 0; + + /// + /// Recover the key payloads without walking the frame. Returns -1 when , + /// in which case the caller must walk instead. + /// + public readonly int TryGetKeys(scoped Span target) + { + if ((_keyMarks & OverflowFlag) != 0) return -1; + var count = 0; + var a = (int)(_keyMarks & SlotMask); + var b = (int)((_keyMarks >> SlotBits) & SlotMask); + if (a != 0) target[count++] = PayloadOf(a); + if (b != 0) target[count++] = PayloadOf(b); + return count; + } + + /// Resolve a against the underlying buffer. + public readonly ReadOnlySpan GetKey(in KeyRange range) => new(_buffer, range.Offset, range.Length); + + /// + /// Given the buffer-absolute offset of a fragment's '$', parse the self-describing length and return + /// the payload range; no length needs to be stored alongside the offset. + /// + private readonly KeyRange PayloadOf(int offset) + { + var buffer = _buffer!; + int i = offset + 1, length = 0; + while (buffer[i] != (byte)'\r') + { + length = (length * 10) + (buffer[i] - (byte)'0'); + i++; + } + + return new KeyRange(i + 2, length); + } + + public void Dispose() + { + var buffer = _buffer; + _buffer = null; + if (buffer is not null) ArrayPool.Shared.Return(buffer); + } + } + + /// + /// EXPERIMENTAL SPIKE. Offset and length of a payload within a 's buffer. + /// + /// + /// Deliberately not System.Range: down-level that has to be a source polyfill, and the polyfill + /// must be internal (a public one would collide with the real type on newer targets), so it cannot + /// appear in API that a consumer might one day see. + /// + internal readonly struct KeyRange + { + public KeyRange(int offset, int length) + { + Offset = offset; + Length = length; + } + + public int Offset { get; } + + public int Length { get; } + } +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs new file mode 100644 index 000000000..487591fbd --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using RESPite.Messages; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Unit tests for the experimental interpolated-string RESP writer spike; see +/// design/interpolated-resp-writer.md. Pure formatting/routing - no server involved. +/// +public class InterpolatedWriterUnitTests +{ + /// Parse a rendered frame back into its arguments, demanding exact consumption. + private static string[] Parse(ReadOnlySpan frame) + { + var reader = new RespReader(frame); + reader.MoveNext(); + Assert.Equal(RespPrefix.Array, reader.Prefix); + var count = reader.AggregateLength(); + var args = new string[count]; + for (int i = 0; i < count; i++) + { + reader.MoveNext(); + Assert.Equal(RespPrefix.BulkString, reader.Prefix); + args[i] = reader.ReadString() ?? ""; + } + + reader.DemandEnd(); // no over- or under-run + return args; + } + + private static string[] Keys(in RespFrame frame) + { + Span ranges = stackalloc KeyRange[2]; + var count = frame.TryGetKeys(ranges); + if (count < 0) return null!; // caller must scan + var keys = new string[count]; + for (int i = 0; i < count; i++) keys[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); + return keys; + } + + [Fact] + public void RendersCommandKeyAndValue() + { + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"myvalue"}"); + + Assert.Equal(3, frame.ArgCount); + Assert.Equal(new[] { "SET", "mykey", "myvalue" }, Parse(frame.Span)); + } + + [Fact] + public void RendersExactBytes() + { + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + + Assert.Equal("*2\r\n$3\r\nGET\r\n$3\r\nabc\r\n", Encoding.UTF8.GetString(frame.Span.ToArray())); + } + + [Theory] + [InlineData(1)] + [InlineData(9)] + [InlineData(10)] // '*NN' - two digits, so the frame start moves + [InlineData(120)] // three + public void HeaderBackfillIsRightAligned(int extraArgs) + { + var ctx = new RespContext(); + var handler = new RespCommandHandler(0, extraArgs + 2, ctx); + handler.AppendFormatted(RedisCommand.SET); + handler.AppendFormatted((RedisKey)"mykey"); + for (int i = 0; i < extraArgs; i++) handler.AppendFormatted((RedisValue)i); + using var frame = handler.Complete(); + + var args = Parse(frame.Span); + Assert.Equal(extraArgs + 2, args.Length); + Assert.Equal("SET", args[0]); + + // the key offset is buffer-absolute, so it survives the header growing + Assert.Equal(new[] { "mykey" }, Keys(frame)); + } + + [Fact] + public void CommandMapRenamesAreApplied() + { + var map = CommandMap.Create(new Dictionary { ["set"] = "xset" }); + var ctx = new RespContext(map); + using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + + Assert.Equal(new[] { "XSET", "k", "v" }, Parse(frame.Span)); + } + + [Fact] + public void DisabledCommandThrows() + { + var map = CommandMap.Create(new Dictionary { ["set"] = null }); + var ctx = new RespContext(map); + + Assert.Throws(() => ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}").Dispose()); + } + + [Fact] + public void CommandMustComeFirst() + { + var ctx = new RespContext(); + Assert.Throws(() => ctx.Execute($"{(RedisKey)"k"}{RedisCommand.GET}").Dispose()); + } + + [Fact] + public void KeyPrefixIsAppliedToTheWire() + { + var ctx = new RespContext().WithKeyPrefix("tenant7:"); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + + Assert.Equal(new[] { "GET", "tenant7:user:1" }, Parse(frame.Span)); + Assert.Equal(new[] { "tenant7:user:1" }, Keys(frame)); + } + + [Fact] + public void KeyPrefixComposesWithAKeyThatAlreadyHasOne() + { + // a key that already carries a prefix, as the KeyPrefixed* decorators produce today + var prefixed = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("inner:"), "user:1"); + var ctx = new RespContext().WithKeyPrefix("outer:"); + using var frame = ctx.Execute($"{RedisCommand.GET}{prefixed}"); + + Assert.Equal(new[] { "GET", "outer:inner:user:1" }, Parse(frame.Span)); + } + + [Fact] + public void BothPrefixMechanismsRenderIdenticalBytes() + { + // decorator-applied prefix (rides on the key) vs context-applied prefix (applied at write time). + // They are different RedisKey VALUES - RedisKey.Equals compares the carried prefix - but they must + // be indistinguishable on the wire, which is what lets the rendered frame serve as a cache key. + var viaDecorator = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("tenant7:"), "user:1"); + using var a = new RespContext().Execute($"{RedisCommand.GET}{viaDecorator}"); + using var b = new RespContext(keyPrefix: "tenant7:").Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + + Assert.True(a.Span.SequenceEqual(b.Span)); + Assert.Equal(new[] { "GET", "tenant7:user:1" }, Parse(a.Span)); + } + +#if NET + [Fact] + public void ComposingBothPrefixMechanismsDoesNotAllocate() + { + // Both mechanisms have to coexist, so this is the permanent hot path - not an interim state. + // RedisKey.WithPrefix has to allocate to combine two prefixes (its "two prefixes; darn" branch) + // because it must hand back a RedisKey; the writer only ever needs the combined bytes. + var decorated = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("inner:"), "user:1"); + var ctx = new RespContext(keyPrefix: "outer:"); + + for (int i = 0; i < 64; i++) ctx.Execute($"{RedisCommand.GET}{decorated}").Dispose(); // warm the pool + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 128; i++) ctx.Execute($"{RedisCommand.GET}{decorated}").Dispose(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(allocated == 0, $"allocated {allocated} bytes over 128 renders"); + } +#endif + + [Fact] + public void NestedWithKeyPrefixComposes() + { + var ctx = new RespContext().WithKeyPrefix("a:").WithKeyPrefix("b:"); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + + Assert.Equal(new[] { "GET", "a:b:k" }, Parse(frame.Span)); + } + + [Fact] + public void ChannelPrefixIsApplied() + { + var ctx = new RespContext(channelPrefix: new RedisChannel("app:", RedisChannel.PatternMode.Literal)); + using var frame = ctx.Execute($"{RedisCommand.PUBLISH}{new RedisChannel("news", RedisChannel.PatternMode.Literal)}{(RedisValue)"hi"}"); + + Assert.Equal(new[] { "PUBLISH", "app:news", "hi" }, Parse(frame.Span)); + } + + [Fact] + public void ChannelPrefixIsSkippedWhenTheChannelOptsOut() + { + // keyspace notification channels are server-generated names, and opt out of the channel prefix + var channel = new RedisChannel("__keyevent@0__:set", RedisChannel.RedisChannelOptions.IgnoreChannelPrefix); + var ctx = new RespContext(channelPrefix: new RedisChannel("app:", RedisChannel.PatternMode.Literal)); + using var frame = ctx.Execute($"{RedisCommand.SUBSCRIBE}{channel}"); + + Assert.Equal(new[] { "SUBSCRIBE", "__keyevent@0__:set" }, Parse(frame.Span)); + } + + [Fact] + public void NoKeysMeansNoSlotAndNoMarks() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + using var frame = ctx.Execute($"{RedisCommand.ECHO}{(RedisValue)"hello"}"); + + Assert.True(frame.HasNoKeys); + Assert.Empty(Keys(frame)); + Assert.Equal(ServerSelectionStrategy.NoSlot, frame.Slot); + } + + [Fact] + public void OneAndTwoKeysResolveWithoutScanning() + { + var ctx = new RespContext(); + + using (var one = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k1"}")) + { + Assert.False(one.KeysNeedScan); + Assert.Equal(new[] { "k1" }, Keys(one)); + } + + using var two = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"src"}{(RedisKey)"dst"}{(RedisValue)"m"}"); + Assert.False(two.KeysNeedScan); + Assert.Equal(new[] { "src", "dst" }, Keys(two)); + } + + [Fact] + public void ThreeKeysFallBackToScanning() + { + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + + Assert.True(frame.KeysNeedScan); + Assert.Null(Keys(frame)); + Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); // still renders correctly + } + + [Fact] + public void StandaloneSkipsSlotComputation() + { + var ctx = new RespContext(serverType: ServerType.Standalone); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"foo"}"); + + Assert.Equal(ServerSelectionStrategy.NoSlot, frame.Slot); + } + + [Fact] + public void ClusterFoldsTheSlotFromTheWrittenBytes() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"foo"}"); + + // published CLUSTER KEYSLOT value + Assert.Equal(12182, frame.Slot); + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"foo"), frame.Slot); + } + + [Fact] + public void SharedHashTagGivesOneSlot() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + using var frame = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"{u1}:a"}{(RedisKey)"{u1}:b"}{(RedisValue)"m"}"); + + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"{u1}:a"), frame.Slot); + Assert.NotEqual(ServerSelectionStrategy.MultipleSlots, frame.Slot); + } + + [Fact] + public void CrossSlotKeysAreDetected() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + using var frame = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisValue)"m"}"); + + Assert.Equal(ServerSelectionStrategy.MultipleSlots, frame.Slot); + } + + [Fact] + public void SlotIsComputedFromThePrefixedKey() + { + var plain = new RespContext(serverType: ServerType.Cluster); + var prefixed = plain.WithKeyPrefix("tenant7:"); + + using var a = plain.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + using var b = prefixed.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + + Assert.NotEqual(a.Slot, b.Slot); + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"tenant7:user:1"), b.Slot); + } + + [Fact] + public void CancellationIsObservedAndTheBufferIsReturned() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var ctx = new RespContext().WithCancellationToken(cts.Token); + + Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); + } + + [Fact] + public void CancellationTokenFlowsThroughWithClones() + { + using var cts = new CancellationTokenSource(); + var ctx = new RespContext().WithCancellationToken(cts.Token).WithKeyPrefix("p:").WithDatabase(3); + + Assert.Equal(cts.Token, ctx.CancellationToken); + Assert.Equal(3, ctx.Database); + Assert.Equal((RedisKey)"p:", ctx.KeyPrefix); + } + + [Fact] + public void MultiByteAndEmptyPayloadsRoundTrip() + { + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"naïve☃"}{(RedisValue)""}"); + + Assert.Equal(new[] { "SET", "naïve☃", "" }, Parse(frame.Span)); + } + + [Fact] + public void LargePayloadForcesBufferGrowthMidBuild() + { + var big = new string('x', 5000); + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)big}"); + + var args = Parse(frame.Span); + Assert.Equal(big, args[2]); + + // the reserved prologue and the key offset must survive the regrow + Assert.Equal(new[] { "k" }, Keys(frame)); + } +} From d7ef6915295895e3e74687bf52d764fe3a0147a2 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 15:51:42 +0100 Subject: [PATCH 005/360] Cache identity needs the database number, not just the frame SELECT is a separate command on the connection, so GET foo on database 0 and on database 3 render byte-identically. Cache identity is therefore (frame, database) - the frame alone is not enough. Easy to overlook precisely because everything else that affects identity IS in the bytes: the key prefix, a renamed command from the CommandMap, and every argument. The frame reads as self-sufficient when it is not. The context already carries Database, so folding it into the hash is trivial; remembering to is the whole cost. Adds DatabaseIsNotPartOfTheRenderedFrame to pin the behaviour, and a design note covering two neighbours to check when the cache is built: the protocol version if raw response bytes are cached (RESP2 and RESP3 shapes differ), and the multiplexer if a process talks to more than one deployment - both likely free by scoping rather than by being in the frame. --- design/interpolated-resp-writer.md | 21 +++++++++++++++++-- .../InterpolatedWriterUnitTests.cs | 15 +++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index ce5849fd7..fa930f9ec 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -386,7 +386,24 @@ All O(1) state, all during the write, none needing a second pass: A lookup is then hash → bucket → `SequenceEqual`, with no walk unless a real collision. -### 6.2 Buffer ownership +### 6.2 The database number is not in the frame + +`SELECT` is a separate command on the connection, so `GET foo` on database 0 and database 3 render +**byte-identically**. Cache identity is therefore `(frame, database)`, never the frame alone. + +This is obvious once stated and very easy to overlook, precisely because everything *else* that affects +identity is already in the bytes — the key prefix, a renamed command from the `CommandMap`, every +argument — so the frame feels self-sufficient. The context already carries `Database`, so the fix is to +fold it into the hash alongside the bytes; the cost is remembering to. + +Pinned by `DatabaseIsNotPartOfTheRenderedFrame`. + +Two neighbours worth checking when the cache is built: the protocol version, if what is cached is raw +*response* bytes (RESP2 and RESP3 shapes differ), and the multiplexer itself, if a process talks to +more than one deployment. Both are naturally scoped per-connection or per-multiplexer, so they are +likely free — but by scoping, not by being in the frame. + +### 6.3 Buffer ownership A pooled buffer **must not** be retained as a dictionary key without ownership transfer — `ArrayPool` reuse would mutate live cache keys, and the failure mode is wrong data served from cache, not a crash. @@ -409,7 +426,7 @@ whole lifetime, so keys can be recovered lazily from a cached entry without re-r would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, reintroduced at a second site. -### 6.3 Exception paths +### 6.4 Exception paths The lowering puts construction and all `Append` calls in the *caller's* frame, before `Execute` is entered: diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 487591fbd..52989ed2a 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -131,6 +131,21 @@ public void KeyPrefixComposesWithAKeyThatAlreadyHasOne() Assert.Equal(new[] { "GET", "outer:inner:user:1" }, Parse(frame.Span)); } + [Fact] + public void DatabaseIsNotPartOfTheRenderedFrame() + { + // SELECT is a separate command on the connection, so the same logical command renders IDENTICALLY + // on different databases. Cache identity therefore needs (frame, database) - the frame alone is not + // enough, which is easy to miss because everything else that matters (prefix, renamed command, + // arguments) IS in the bytes. + using var a = new RespContext(database: 0).Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + using var b = new RespContext(database: 3).Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + + Assert.True(a.Span.SequenceEqual(b.Span)); + Assert.Equal(0, new RespContext(database: 0).Database); + Assert.Equal(3, new RespContext(database: 3).Database); + } + [Fact] public void BothPrefixMechanismsRenderIdenticalBytes() { From 5eba52fb815b783f93152350c4d9b6615596845b Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 15:53:33 +0100 Subject: [PATCH 006/360] Protocol version is not a cache identity input RESP2 and RESP3 response shapes differ, but the protocol does not belong in the cache key. It is negotiated per PhysicalConnection (SetProtocol, propagated to the bridge; ServerEndPoint reads it back from the interactive connection), so mixed protocols within a single multiplexer are structurally reachable at the same time - a cluster mid-upgrade, or a primary and replica at different versions - not just across a deployment change. That is fine: in any deployment where it can happen, the result processors have to be shape-tolerant anyway, which RESP3 support requires of them generally, so a cached response in either shape still parses. What is left is hit rate rather than correctness - both shapes can be cached for the same logical (frame, database) while a deployment is mixed - and even that disappears if the cache stores parsed results instead of raw response bytes. --- design/interpolated-resp-writer.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index fa930f9ec..5e3bac857 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -398,10 +398,25 @@ fold it into the hash alongside the bytes; the cost is remembering to. Pinned by `DatabaseIsNotPartOfTheRenderedFrame`. -Two neighbours worth checking when the cache is built: the protocol version, if what is cached is raw -*response* bytes (RESP2 and RESP3 shapes differ), and the multiplexer itself, if a process talks to -more than one deployment. Both are naturally scoped per-connection or per-multiplexer, so they are -likely free — but by scoping, not by being in the frame. +Two neighbours, which resolve differently. + +**The multiplexer**, if a process talks to more than one deployment: free by scoping, assuming the cache +is per-multiplexer. Worth not hoisting it somewhere more shared without revisiting. + +**The protocol version** is not an identity input, despite RESP2 and RESP3 response shapes differing. +It is negotiated per `PhysicalConnection` (`SetProtocol`, `PhysicalConnection.cs:372`, propagated to the +bridge; `ServerEndPoint.cs:148` reads it back from the interactive connection), so mixed protocols +within one multiplexer are structurally reachable *simultaneously* — a cluster mid-upgrade, or a primary +and replica at different versions — not merely over time. + +That is fine, because in any deployment where it can happen the result processors must already be +shape-tolerant, which RESP3 support requires of them generally; a cached response in either shape still +parses. What remains is a **hit-rate** question, not correctness: both shapes can end up cached for the +same logical `(frame, database)`, costing duplicate entries while a deployment is mixed. + +It disappears entirely if the cache stores *parsed results* rather than raw response bytes. Worth +deciding deliberately, since that is the difference between the protocol being a non-issue and being a +standing hit-rate tax. ### 6.3 Buffer ownership From 6c39293fa05dc7302ff69c4af3fe4139684c9b6a Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 15:59:38 +0100 Subject: [PATCH 007/360] Correct the protocol claim; add result caching (blob vs value) The previous note claimed mixed protocols could cost duplicate cache entries. That was wrong: the key is (frame, database), so both shapes collide on the same entry. There is exactly one, holding whichever protocol wrote it last, and any reader parses it because the result processors have to be shape-tolerant anyway. No duplicates and no hit-rate cost - the protocol simply does not participate. Adds 6.3 on caching the result, following HybridCache: cache the blob and re-run the parser by default, and cache the value only when the type is detectably immutable or explicitly opted in. Here the blob is the raw RESP response and the parser is the ResultProcessor, which is also what makes 6.2 hold - the processor is the single place tolerating RESP2 versus RESP3. Eligibility for value-caching is subtler here than in HybridCache, because it is instance-dependent rather than type-dependent. RedisValue and RedisKey are readonly structs but not deeply immutable: the byte[] conversion returns the INTERNAL array when the value is fully array-backed, rather than a copy, so a caller can mutate it and poison every other holder. StorageType distinguishes the cases, so the check is cheap - but it is a check on the value, not on typeof(T). Arrays and RedisResult are never value-cacheable, and array returns are common across this API, which is exactly what blob-by-default prevents. --- design/interpolated-resp-writer.md | 53 +++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 5e3bac857..ebf017f62 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -403,22 +403,51 @@ Two neighbours, which resolve differently. **The multiplexer**, if a process talks to more than one deployment: free by scoping, assuming the cache is per-multiplexer. Worth not hoisting it somewhere more shared without revisiting. -**The protocol version** is not an identity input, despite RESP2 and RESP3 response shapes differing. +**The protocol version is not an identity input**, despite RESP2 and RESP3 response shapes differing. It is negotiated per `PhysicalConnection` (`SetProtocol`, `PhysicalConnection.cs:372`, propagated to the bridge; `ServerEndPoint.cs:148` reads it back from the interactive connection), so mixed protocols -within one multiplexer are structurally reachable *simultaneously* — a cluster mid-upgrade, or a primary -and replica at different versions — not merely over time. +within one multiplexer are reachable *simultaneously* — a cluster mid-upgrade, or a primary and replica +at different versions. -That is fine, because in any deployment where it can happen the result processors must already be -shape-tolerant, which RESP3 support requires of them generally; a cached response in either shape still -parses. What remains is a **hit-rate** question, not correctness: both shapes can end up cached for the -same logical `(frame, database)`, costing duplicate entries while a deployment is mixed. +That is still not a reason to key on it. Since the key is `(frame, database)`, both shapes collide on +the same entry: there is exactly one, holding whichever protocol wrote it last, and any reader parses it +because the result processors have to be shape-tolerant anyway — which RESP3 support requires of them +generally. No duplicate entries, no hit-rate cost; the protocol simply does not participate. -It disappears entirely if the cache stores *parsed results* rather than raw response bytes. Worth -deciding deliberately, since that is the difference between the protocol being a non-issue and being a -standing hit-rate tax. +### 6.3 Caching the result: blob by default, value by exception -### 6.3 Buffer ownership +`HybridCache` is the model worth copying. Its default is to cache the serialized **blob** and re-run the +deserializer per read; it caches the **value** only when the type is detectably immutable or the caller +has explicitly said so — which is a large win for strings. + +Applied here, the default is to cache the raw RESP response bytes and re-run the `ResultProcessor`. +That is safe for any `T`, and it is the same property that makes §6.2 work: the processor is the single +place that tolerates RESP2 versus RESP3, so a cached blob is readable whichever shape it holds. + +Value-caching is then the optimisation, and eligibility here is subtler than `HybridCache`'s, because it +is **instance**-dependent rather than purely type-dependent: + +| | By value? | +| --- | --- | +| `string`, `long`, `bool`, `double` | yes, unconditionally | +| `RedisValue`, `RedisKey` | **only sometimes** — see below | +| `RedisValue[]`, `RedisKey[]`, `RedisResult` | no | + +`RedisValue` and `RedisKey` are `readonly struct`s, but they are not *deeply* immutable: the `byte[]` +conversion returns the **internal array** when the value is fully array-backed +(`RedisValue.cs:1198-1200`; `RedisKey` via `TryGetSimpleBuffer`), rather than a copy. A caller can take +that array and mutate it, poisoning every other holder of the same cached instance. `StorageType` +distinguishes the cases — string-, integer-, double- and short-blob-backed values either copy or never +expose an array; only the full-array case leaks — so the check is cheap, but it is a check on the +*value*, not on `typeof(T)`. + +Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default +exists to prevent, so the default matters more here than it might elsewhere. + +Nothing in the repo is annotated for this today — no `[ImmutableObject]`, no `HybridCache` reference — so +the explicit opt-in marker would be new. + +### 6.4 Buffer ownership A pooled buffer **must not** be retained as a dictionary key without ownership transfer — `ArrayPool` reuse would mutate live cache keys, and the failure mode is wrong data served from cache, not a crash. @@ -441,7 +470,7 @@ whole lifetime, so keys can be recovered lazily from a cached entry without re-r would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, reintroduced at a second site. -### 6.4 Exception paths +### 6.5 Exception paths The lowering puts construction and all `Append` calls in the *caller's* frame, before `Execute` is entered: From aa3e07a88a92603c929e03a417250a301fca59b8 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:01:57 +0100 Subject: [PATCH 008/360] Cite the HybridCache reuse-objects mechanism accurately Corrects the earlier paraphrase against the docs. HybridCache deserializes on every retrieval by default, so each concurrent caller gets a separate instance; that is deliberate, preserving the IDistributedCache behaviour callers migrate from so that adopting it cannot introduce concurrency bugs. Reuse is opt-in and requires BOTH that the type is sealed and that it carries [ImmutableObject(true)] - not detection, and not either one alone. That opt-in does not port directly here. RedisValue and RedisKey are readonly structs, so the sealed half is free, but [ImmutableObject(true)] would be untrue: the byte[] conversion hands back the internal array when the value is fully array-backed, so a caller can mutate it and poison every other holder. Only the full-array case leaks - StorageType distinguishes it cheaply - but that makes eligibility a predicate over the value rather than over typeof(T), which a type-level attribute cannot express. Either the test is a runtime one, or array-backed values are copied on the way in. Also records that HybridCache's own cache-key guidance already recommends writing the key as an interpolated string inline at the call site, explicitly so that planned improvements can avoid allocating a string for the key - the same handler technique this document is about, in the public guidance of the library whose caching model we are copying. Reference: https://learn.microsoft.com/aspnet/core/performance/caching/hybrid?view=aspnetcore-10.0#reuse-objects --- design/interpolated-resp-writer.md | 50 +++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index ebf017f62..66ea58559 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -416,16 +416,23 @@ generally. No duplicate entries, no hit-rate cost; the protocol simply does not ### 6.3 Caching the result: blob by default, value by exception -`HybridCache` is the model worth copying. Its default is to cache the serialized **blob** and re-run the -deserializer per read; it caches the **value** only when the type is detectably immutable or the caller -has explicitly said so — which is a large win for strings. +`HybridCache` is the model worth copying — see +[Reuse objects](https://learn.microsoft.com/aspnet/core/performance/caching/hybrid?view=aspnetcore-10.0#reuse-objects). + +Its default is that every retrieval deserializes, so each concurrent caller gets a **separate instance**. +That is deliberate: it preserves the `IDistributedCache` behaviour most callers are migrating from, so +adopting `HybridCache` cannot introduce concurrency bugs. (`string` and `byte[]` are handled internally; +everything else goes through a serializer.) Reuse is opt-in, and requires **both**: + +- the type is `sealed`, and +- the type carries `[ImmutableObject(true)]`. Applied here, the default is to cache the raw RESP response bytes and re-run the `ResultProcessor`. That is safe for any `T`, and it is the same property that makes §6.2 work: the processor is the single place that tolerates RESP2 versus RESP3, so a cached blob is readable whichever shape it holds. -Value-caching is then the optimisation, and eligibility here is subtler than `HybridCache`'s, because it -is **instance**-dependent rather than purely type-dependent: +Value-caching is then the optimisation — and **the `HybridCache` opt-in does not port directly**, because +eligibility here is *instance*-dependent rather than type-dependent: | | By value? | | --- | --- | @@ -433,19 +440,34 @@ is **instance**-dependent rather than purely type-dependent: | `RedisValue`, `RedisKey` | **only sometimes** — see below | | `RedisValue[]`, `RedisKey[]`, `RedisResult` | no | -`RedisValue` and `RedisKey` are `readonly struct`s, but they are not *deeply* immutable: the `byte[]` -conversion returns the **internal array** when the value is fully array-backed -(`RedisValue.cs:1198-1200`; `RedisKey` via `TryGetSimpleBuffer`), rather than a copy. A caller can take -that array and mutate it, poisoning every other holder of the same cached instance. `StorageType` -distinguishes the cases — string-, integer-, double- and short-blob-backed values either copy or never -expose an array; only the full-array case leaks — so the check is cheap, but it is a check on the -*value*, not on `typeof(T)`. +`RedisValue` and `RedisKey` are `readonly struct`s, so the `sealed` half is trivially satisfied — but +they are not *deeply* immutable, so `[ImmutableObject(true)]` would be a lie. The `byte[]` conversion +returns the **internal array** when the value is fully array-backed (`RedisValue.cs:1198-1200`; +`RedisKey` via `TryGetSimpleBuffer`) rather than a copy, so a caller can take that array, mutate it, and +poison every other holder of the same cached instance. `StorageType` distinguishes the cases — +string-, integer-, double- and short-blob-backed values either copy or never expose an array; only the +full-array case leaks — so the check is cheap, but it is a check on the *value*, not on `typeof(T)`. + +A type-level attribute therefore cannot express it. Either the eligibility test is a runtime predicate +over the value, or array-backed values are copied on the way into the cache. Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default exists to prevent, so the default matters more here than it might elsewhere. -Nothing in the repo is annotated for this today — no `[ImmutableObject]`, no `HybridCache` reference — so -the explicit opt-in marker would be new. +Nothing in the repo is annotated for this today — no `[ImmutableObject]`, no `HybridCache` reference. + +### 6.3.1 The same trick, already anticipated + +`HybridCache`'s own cache-key guidance recommends writing the key as an interpolated string *inline at +the call site*: + +> Notice that the inline interpolated string syntax (`$"..."` [...]) is directly inside the +> `GetOrCreateAsync` call. This syntax is recommended when using `HybridCache`, as it allows for planned +> future improvements that bypass the need to allocate a `string` for the key in many scenarios. + +That is this document's technique, in the public guidance of the library whose caching model §6.3 is +copying: keep the interpolation at the call site so a handler can consume the parts without ever +materialising a `string`. Worth knowing that the shape is already established rather than novel. ### 6.4 Buffer ownership From 24bc01f82d6ccd02f84ac21169130d93d5e7783f Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:03:25 +0100 Subject: [PATCH 009/360] Frame the notes as ideas; make RedisValue eligibility StorageType-oriented Adds an explicit header note that the document is exploratory - a log of what was tried and what seems to follow, not a plan of record. Replaces the type-oriented value-caching table with one keyed on StorageType, since for RedisValue the answer depends on the value rather than the type: - Null/Int64/UInt64/Double (overlapped field), String, and ShortBlob (1-8 bytes inline) are self-contained and safe. - ByteArray aliases: the byte[] conversion returns the INTERNAL array in exactly one case - ByteArray spanning the whole array - while every other branch copies. A caller can mutate it and poison other holders. - MemoryManager and Sequence are a separate hazard: they reference memory the value does not own, possibly a pooled lease that is later recycled, so they are unsafe to retain at all regardless of mutation - the same lifetime problem as buffer ownership, from the other direction. So eligibility wants to be a value-oriented predicate over StorageType rather than a type-level marker; the alternative is copying on the way in for the unsafe kinds, which allocates exactly where value-caching was meant to save. --- design/interpolated-resp-writer.md | 47 +++++++++++++++++------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 66ea58559..0eb07f16e 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1,7 +1,8 @@ # Interpolated-string RESP writer -Design notes for the v3 writer work. This records what was verified empirically, what follows from it, -and what is still open. A working spike lives in `src/StackExchange.Redis/Interpolated/` with unit tests +**Exploratory notes — ideas, not decisions.** Nothing here is agreed or committed to; it is a log of +what was tried, what was verified empirically, what seems to follow, and what is still open. Treat +recommendations as "this looked right at the time", not as a plan of record. A working spike lives in `src/StackExchange.Redis/Interpolated/` with unit tests in `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs`; everything there is `internal`, so there is no public API commitment yet. @@ -431,25 +432,31 @@ Applied here, the default is to cache the raw RESP response bytes and re-run the That is safe for any `T`, and it is the same property that makes §6.2 work: the processor is the single place that tolerates RESP2 versus RESP3, so a cached blob is readable whichever shape it holds. -Value-caching is then the optimisation — and **the `HybridCache` opt-in does not port directly**, because -eligibility here is *instance*-dependent rather than type-dependent: +Value-caching is then the optimisation — and **the `HybridCache` opt-in does not port directly**. +`RedisValue` and `RedisKey` are `readonly struct`s, so the `sealed` half is free, but +`[ImmutableObject(true)]` would be untrue of them, and a type-level attribute cannot express why: for +`RedisValue` the answer depends on the *value*, specifically its `StorageType`. -| | By value? | -| --- | --- | -| `string`, `long`, `bool`, `double` | yes, unconditionally | -| `RedisValue`, `RedisKey` | **only sometimes** — see below | -| `RedisValue[]`, `RedisKey[]`, `RedisResult` | no | - -`RedisValue` and `RedisKey` are `readonly struct`s, so the `sealed` half is trivially satisfied — but -they are not *deeply* immutable, so `[ImmutableObject(true)]` would be a lie. The `byte[]` conversion -returns the **internal array** when the value is fully array-backed (`RedisValue.cs:1198-1200`; -`RedisKey` via `TryGetSimpleBuffer`) rather than a copy, so a caller can take that array, mutate it, and -poison every other holder of the same cached instance. `StorageType` distinguishes the cases — -string-, integer-, double- and short-blob-backed values either copy or never expose an array; only the -full-array case leaks — so the check is cheap, but it is a check on the *value*, not on `typeof(T)`. - -A type-level attribute therefore cannot express it. Either the eligibility test is a runtime predicate -over the value, or array-backed values are copied on the way into the cache. +| `StorageType` | Backing | Safe to cache by value? | +| --- | --- | --- | +| `Null`, `Int64`, `UInt64`, `Double` | the overlapped field | yes — self-contained | +| `String` | a `string` | yes — immutable | +| `ShortBlob` | 1-8 bytes inline in the overlapped field | yes — self-contained | +| `ByteArray` | a `byte[]` | **no** — see below | +| `MemoryManager`, `Sequence` | memory owned elsewhere | **no** — see below | + +**`ByteArray` aliases.** The `byte[]` conversion returns the **internal array** in exactly one case — +`StorageType.ByteArray` where the value spans the whole array (`RedisValue.cs:1198-1200`; `RedisKey` +does the same via `TryGetSimpleBuffer`). Every other branch copies. So a caller can take that array, +mutate it, and poison every other holder of the same cached instance. + +**`MemoryManager`/`Sequence` are a different hazard.** These reference memory the `RedisValue` does not +own, which may be a pooled lease that is later recycled. That is unsafe to *retain* at all, regardless +of mutation — the same lifetime problem as §6.4, arriving from the other direction. + +So the eligibility test wants to be a value-oriented predicate over `StorageType`, not a type-level +marker — cheap to evaluate, but evaluated per value. The alternative is to copy on the way in for the +unsafe kinds, which costs an allocation exactly where value-caching was supposed to save one. Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default exists to prevent, so the default matters more here than it might elsewhere. From 96bca45c8d574c2005eae36bc79d7dc93769f20c Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:05:23 +0100 Subject: [PATCH 010/360] Idea: never hand the cache an exact-size array The RedisValue byte[] aliasing branch fires only on _index is 0 && _length == arr.Length, so ensuring a cached value is never backed by an exactly-sized array forces the copying branch. That is structural rather than incidental - byte[] cannot express a partial view, so the operator has to copy - and close to free, since a pooled rent is over-sized by construction. It defends one route rather than the invariant: the ReadOnlyMemory conversion returns a window onto the internal array at ANY size, and ReadOnlySequence delegates to it. ReadOnlyMemory is read-only only by convention - MemoryMarshal.AsMemory makes it mutable in one call with no unsafe. Whether the mutation half matters is a judgement call; the lifetime half is not. A well-behaved caller can hold the returned memory past eviction or past the pooled array being recycled, with no misuse at all, which over-sizing does nothing for. Notes that if adopted it needs a comment and a test, since deliberately over-allocating to defeat an optimisation reads as waste to whoever finds it later. --- design/interpolated-resp-writer.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 0eb07f16e..7cd6b3a8b 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -458,6 +458,34 @@ So the eligibility test wants to be a value-oriented predicate over `StorageType marker — cheap to evaluate, but evaluated per value. The alternative is to copy on the way in for the unsafe kinds, which costs an allocation exactly where value-caching was supposed to save one. +#### Idea: never hand the cache an exact-size array + +The aliasing branch fires only on `_index is 0 && _length == arr.Length`, so ensuring a cached value is +never backed by an exactly-sized array forces the copying branch instead. That is structural rather than +incidental — `byte[]` cannot express a partial view, so the operator *has* to copy — and it is close to +free, because a pooled rent is over-sized by construction. + +It defends one route, though, not the invariant: + +| Route | Over-sizing defends? | Why | +| --- | --- | --- | +| `(byte[])` | **yes** | `byte[]` cannot be a partial view, so the operator must copy | +| `(ReadOnlyMemory)` | no | returns `new ReadOnlyMemory(arr, _index, _length)` at any size (`RedisValue.cs:1353`) | +| `(ReadOnlySequence)` | no | delegates to the above | + +`ReadOnlyMemory` is read-only only by convention: `MemoryMarshal.AsMemory` makes it mutable in one +call, with no `unsafe`. Whether that counts is a judgement call — reaching for `MemoryMarshal` to mutate +someone else's read-only memory is arguably "you broke it, you own it", and on that reading over-sizing +does close the practical *mutation* surface. + +The *lifetime* half is not a judgement call: a well-behaved caller can hold the returned +`ReadOnlyMemory` past eviction, or past the pooled array being recycled, with no misuse at all. +That is §6.4 again, and over-sizing does nothing for it. + +If this is adopted it needs a comment and a test, because deliberately over-allocating to defeat an +optimisation reads as waste to anyone who finds it later — and "not exact-size-backed" is directly +assertable. + Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default exists to prevent, so the default matters more here than it might elsewhere. From c9242ab780ad1fd597150d3c5c7eb93a3fdedc63 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:05:54 +0100 Subject: [PATCH 011/360] Better variant: start the cached RedisValue at index 1 Breaking the _index is 0 half costs exactly one byte and is a property of how the RedisValue is constructed - under our control - rather than depending on the allocator having over-sized the array. Records that this is NOT already true for response-derived values, contrary to what the pooled-buffer intuition suggests: RedisValue.FromRaw copies anything over MaxInlineBytes (8) into an exactly-sized array at index 0, so every response payload over 8 bytes is precisely the aliasing case. That also means byte[] blob = db.StringGet(key) is zero-copy today, so applying index-1 blanket in FromRaw would turn a common pattern into a copy per call - a real pessimisation rather than a free byte. At cache insert rather than universally it is better than an eager defensive copy, because it makes the copy lazy: a hit that never asks for byte[] pays nothing, and one that does pays exactly the copy it needed anyway. The ReadOnlyMemory caveat is unchanged - that conversion windows in at any index. --- design/interpolated-resp-writer.md | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 7cd6b3a8b..e4278dbe5 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -482,9 +482,36 @@ The *lifetime* half is not a judgement call: a well-behaved caller can hold the `ReadOnlyMemory` past eviction, or past the pooled array being recycled, with no misuse at all. That is §6.4 again, and over-sizing does nothing for it. -If this is adopted it needs a comment and a test, because deliberately over-allocating to defeat an -optimisation reads as waste to anyone who finds it later — and "not exact-size-backed" is directly -assertable. +**Better variant: start at index 1.** Breaking the `_index is 0` half instead costs exactly one byte and +is a property of how the `RedisValue` is *constructed* — fully under our control — rather than depending +on the allocator having over-sized the array. + +Note it is **not** already true for response-derived values. `RedisValue.FromRaw` copies anything over +`MaxInlineBytes` (8) into an exactly-sized array at index 0: + +```csharp +internal static RedisValue FromRaw(ReadOnlySpan bytes) +{ + if (bytes.IsEmpty) return EmptyString; + if (bytes.Length <= MaxInlineBytes) return new RedisValue(bytes); // inline + return bytes.ToArray(); // exact-size, index 0 +} +``` + +So every response payload over 8 bytes is exactly the aliasing case — which also means +`byte[] blob = db.StringGet(key)` is **zero-copy today**. Applying index-1 blanket in `FromRaw` would +turn that common pattern into a copy per call: a real pessimisation, not a free byte. + +Applied at *cache insert* rather than universally, though, it is better than an eager defensive copy, +because **it makes the copy lazy**: a cache hit that never asks for `byte[]` pays nothing, and one that +does pays exactly the copy it needed for safety anyway. + +The `ReadOnlyMemory` caveat above is unchanged either way — that conversion windows into the array +at any index. + +If this is adopted it needs a comment and a test, because a deliberate off-by-one (or a deliberate +over-allocation) reads as waste or as a bug to anyone who finds it later — and both "not +exact-size-backed" and "not index 0" are directly assertable. Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default exists to prevent, so the default matters more here than it might elsewhere. From 7858dbae87f04ba2da0e1cf299b5b692d99cb014 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:07:05 +0100 Subject: [PATCH 012/360] Windows, not copies: the aliasing hack becomes unnecessary If the cache entry pins the buffer, a cached value need not be copied out of it at all. The payload is a slice of the frame, between $len\r\n and the trailing \r\n, so a RedisValue constructed over that buffer has _index > 0 AND _length < arr.Length - both halves of the aliasing condition fail on their own, because the trim was needed anyway. No deliberate off-by-one, no over-allocation, and nothing to explain to whoever reads it next. The machinery exists: the ReadOnlyMemory constructor takes exactly that shape, and values of 8 bytes or fewer still go inline as a self-contained ShortBlob, so the small case has no coupling at all. This also collapses the blob-versus-value distinction for blob-shaped payloads - a windowed RedisValue is both - so re-materialising is an offset computation rather than a parse, and the immutability question that motivated value-caching does not arise, because nothing was copied out to alias. Reconciles a tension in the section above, which had marked MemoryManager and Sequence unsafe to retain: under lease-pinning they are the preferred representation rather than a hazard. What remains is lifetime, sharper rather than softer - the handed-out value points into the entry's lease. The conversions are on our side ((byte[]) and (string) both copy out of a windowed value), so the exposure is narrowly a caller retaining the RedisValue itself and materialising after eviction. --- design/interpolated-resp-writer.md | 33 +++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e4278dbe5..3e323f482 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -451,8 +451,9 @@ does the same via `TryGetSimpleBuffer`). Every other branch copies. So a caller mutate it, and poison every other holder of the same cached instance. **`MemoryManager`/`Sequence` are a different hazard.** These reference memory the `RedisValue` does not -own, which may be a pooled lease that is later recycled. That is unsafe to *retain* at all, regardless -of mutation — the same lifetime problem as §6.4, arriving from the other direction. +own, which may be a pooled lease that is later recycled. Unsafe to *retain* — unless the lease is pinned, +which is exactly what §6.4 has the cache entry doing. See "windows, not copies" below: under pinning +these stop being a hazard and become the preferred representation. So the eligibility test wants to be a value-oriented predicate over `StorageType`, not a type-level marker — cheap to evaluate, but evaluated per value. The alternative is to copy on the way in for the @@ -509,9 +510,31 @@ does pays exactly the copy it needed for safety anyway. The `ReadOnlyMemory` caveat above is unchanged either way — that conversion windows into the array at any index. -If this is adopted it needs a comment and a test, because a deliberate off-by-one (or a deliberate -over-allocation) reads as waste or as a bug to anyone who finds it later — and both "not -exact-size-backed" and "not index 0" are directly assertable. +#### Better still: windows, not copies — and the hack disappears + +If the cache entry pins the buffer (§6.4), a cached value need not be copied out of it *at all*. The +payload is a slice of the frame, sitting between `$len\r\n` and the trailing `\r\n` — so a `RedisValue` +constructed over that buffer has `_index > 0` **and** `_length < arr.Length`. Both halves of the aliasing +condition fail on their own, because the trim was required regardless. No deliberate off-by-one, no +deliberate over-allocation, nothing to explain to a future reader. + +The machinery already exists: the `ReadOnlyMemory` constructor takes exactly this shape +(`MemoryMarshal.TryGetArray` → `_index = segment.Offset; _length = segment.Count; _obj = segment.Array`), +and values of 8 bytes or fewer still go inline as a self-contained `ShortBlob`, so the small case has no +coupling at all. + +This also collapses the blob-versus-value distinction for blob-shaped payloads: a `RedisValue` windowed +onto the cached buffer *is* both. Re-materialising it is an offset computation rather than a parse, so +the "re-run the parser" default costs almost nothing — and the immutability question that motivated +value-caching does not arise, because nothing was ever copied out to alias. + +What remains is lifetime, and it is sharper rather than softer: the handed-out value now points *into* +the entry's lease, so a caller holding a `RedisValue` across eviction is looking at recycled memory. Note +the conversions are on our side here — `(byte[])` and `(string)` both copy out of a windowed value — so +the danger is narrowly a caller who retains the `RedisValue` itself and materialises later. + +That is the same §6.4 problem, but concentrated in one place (the entry's lease) rather than spread +across copies, which is probably where you want it. Array returns are common across this API, and they are exactly the poisoning hazard that blob-by-default exists to prevent, so the default matters more here than it might elsewhere. From 3c3e7f112e8abde5319772b5cef5e9cc47891c23 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 16:08:01 +0100 Subject: [PATCH 013/360] Document the gaps: writer lineage, channel prefix, and the spike Audited the document against the discussion and found three things that had only ever reached commit messages. Adds 3.4, on the context being MessageWriter's parameter list rather than a new idea - MessageWriter's constructor already takes (channelPrefix, map, writer), and TestHarness goes further and carries all three prefixes, making it the closest prototype already in the tree. Tabulates the asymmetry the context is meant to end: the channel prefix is writer state applied at write time and conditional on IgnoreChannelPrefix, while the key prefix rides on the RedisKey itself. Records that keyspace and keyevent channels opt out deliberately because they are server-generated names, so AppendFormatted(RedisChannel) has to honour that, and that the read side already strips the channel prefix while keys never got the equivalent. Adds 3.5, on a struct context having to be valid in its default state: new RespContext() binds the implicit parameterless constructor that zeroes every field, not an all-optional-arguments one, so nothing may be assumed non-null. Found the hard way - it fails at the first command rather than at construction. Adds section 9 describing the in-repo spike: the files, that it is green on net10.0/net8.0 with net481 compiling and Release analyzers clean, and what each test pins, grouped by the section it belongs to. States plainly what the spike does NOT do - nothing dispatches, nothing caches, the read half is untouched - so that threading the context through to result processing is recorded as design rather than demonstration. Also fixes heading-level drift from the incremental edits and renumbers the trailing sections. --- design/interpolated-resp-writer.md | 100 ++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3e323f482..312e76c5c 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -245,6 +245,51 @@ If the context is unavailable for some path, command resolution can be **deferre handler stores the `RedisCommand` and the concrete implementation resolves it at `Close`/`Execute`. That is the same mechanism §3.2 already needs, and it keeps mocks working. +### 3.4 The context is not a new idea — it is `MessageWriter`'s parameter list + +Long term this replaces `MessageWriter`, and that is the clearest way to see what the context is for: +`MessageWriter`'s constructor **already takes it**. + +```csharp +public MessageWriter(byte[]? channelPrefix, CommandMap? map, IBufferWriter writer) +``` + +`TestHarness` — already `[Experimental]`, already built for "render RESP and inspect the bytes" — goes +one further and carries all three prefixes: + +```csharp +public class TestHarness(CommandMap? commandMap = null, RedisChannel channelPrefix = default, RedisKey keyPrefix = default) +``` + +So the context is that triple plus the routing and cancellation state (`Database`, `ServerType`, +`CancellationToken`). `TestHarness` is the closest thing to a prototype already in the tree. + +**The two prefixes reach the wire by different routes today**, which is the asymmetry the context is +meant to end: + +| | How it is applied today | Conditional? | +| --- | --- | --- | +| `ChannelPrefix` | writer state, applied at write time (`MessageWriter.cs:77`) | yes — skipped when `channel.IgnoreChannelPrefix` | +| `KeyPrefix` | rides on the `RedisKey` itself, put there upstream by the `KeyPrefixed*` decorators | no | + +`TestHarness` mirrors that split exactly — it hands `ChannelPrefix` to the `MessageWriter` but simulates +the decorator for keys by rewriting the arguments (`TestHarness.cs:133`). + +`IgnoreChannelPrefix` is not incidental: keyspace and keyevent notification channels are server-generated +names and opt out (`RedisChannel.cs:336`, `:422`), so `AppendFormatted(RedisChannel)` has to honour it +rather than prefixing unconditionally. The read side already strips the channel prefix +(`PhysicalConnection.Read.cs:817`); keys never got the equivalent, which is §8.4's read-half problem. + +### 3.5 A struct context must be valid in its `default` state + +If the context is a `struct`, `new RespContext()` binds the **implicit parameterless constructor** that +zeroes every field — *not* an all-optional-arguments constructor, however tempting that looks. So no +field may be assumed non-null, and `CommandMap` has to fall back to `CommandMap.Default` on read. + +Found the hard way: every test in the spike threw `NullReferenceException` at the first +`AppendFormatted(RedisCommand)`. It fails at the first command rather than at construction, which is the +wrong end to debug from. + --- ## 4. Deferred composition @@ -541,7 +586,7 @@ exists to prevent, so the default matters more here than it might elsewhere. Nothing in the repo is annotated for this today — no `[ImmutableObject]`, no `HybridCache` reference. -### 6.3.1 The same trick, already anticipated +#### The same trick, already anticipated `HybridCache`'s own cache-key guidance recommends writing the key as an interpolated string *inline at the call site*: @@ -715,7 +760,7 @@ before both the write and the slot (§5.1). --- -## 8.4 Key prefixes: both mechanisms, permanently +### 8.4 Key prefixes: both mechanisms, permanently The two prefix mechanisms coexist for good — the context's prefix, and the prefix a `RedisKey` already carries from a `KeyPrefixed*` decorator. That is awkward conceptually but free in the writer: @@ -733,7 +778,7 @@ render byte-identically — pinned by `BothPrefixMechanismsRenderIdenticalBytes` rendered frame serve as a cache key (§6): cache identity must come off the frame, never off the key object. -### The new `KeyPrefixedDatabase` +#### The new `KeyPrefixedDatabase` The write half collapses to `localCtx = downstreamCtx.WithKeyPrefix(prefix)` with no per-method overrides — roughly 2600 lines of forwarding in `KeyspaceIsolation/` become one context clone. @@ -768,7 +813,46 @@ keyspace notifications, and script/`Execute` results. --- -## 9. Open questions +## 9. The spike in this repo + +A working spike, all `internal`, so there is no public API commitment yet. + +| File | What it is | +| --- | --- | +| `src/StackExchange.Redis/FrameworkShims.InterpolatedStringHandler.cs` | the attribute polyfill (§1), same shape as the `IsExternalInit` shim | +| `src/StackExchange.Redis/Interpolated/RespContext.cs` | CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, CancellationToken; `With*` clones; `Execute` | +| `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | +| `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 26 tests | + +Green on net10.0 and net8.0; net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. + +What the tests pin, grouped by the section they belong to: + +- **Framing** — `RendersCommandKeyAndValue`, `RendersExactBytes`, `MultiByteAndEmptyPayloadsRoundTrip`, + `LargePayloadForcesBufferGrowthMidBuild` (forces a pool regrow *after* the prologue is reserved). +- **Header back-fill (§4)** — `HeaderBackfillIsRightAligned`, theory over 1/9/10/120 extra arguments, so + the frame start moves as `*N` gains digits; it also asserts the key offset survives that. +- **CommandMap (§2.4)** — `CommandMapRenamesAreApplied`, `DisabledCommandThrows`, `CommandMustComeFirst`. +- **Prefixes (§3.4, §8.4)** — `KeyPrefixIsAppliedToTheWire`, `KeyPrefixComposesWithAKeyThatAlreadyHasOne`, + `NestedWithKeyPrefixComposes`, `BothPrefixMechanismsRenderIdenticalBytes`, + `ComposingBothPrefixMechanismsDoesNotAllocate`, `ChannelPrefixIsApplied`, + `ChannelPrefixIsSkippedWhenTheChannelOptsOut`. +- **Keys and routing (§5)** — `NoKeysMeansNoSlotAndNoMarks`, `OneAndTwoKeysResolveWithoutScanning`, + `ThreeKeysFallBackToScanning`, `StandaloneSkipsSlotComputation`, + `ClusterFoldsTheSlotFromTheWrittenBytes`, `SharedHashTagGivesOneSlot`, `CrossSlotKeysAreDetected`, + `SlotIsComputedFromThePrefixedKey`. +- **Cache identity (§6.2)** — `DatabaseIsNotPartOfTheRenderedFrame`. +- **Cancellation (§3.3)** — `CancellationIsObservedAndTheBufferIsReturned`, + `CancellationTokenFlowsThroughWithClones`. + +**What the spike does not do:** it stops at "the right bytes were rendered, and we know which arguments +were keys". Nothing dispatches, nothing caches, and the read half (§8.4) is untouched — so the claim +that the context can be threaded through to result processing is design, not demonstration. + +--- + +## 10. Open questions - **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit cursor by its arg count, not by 1. Either forbid keys in `Raw` (rule 5) or have `Raw` carry its own @@ -790,11 +874,11 @@ keyspace notifications, and script/`Execute` results. --- -## 10. Verification log +## 11. Verification log -Everything above marked "verified" was compiled and, where runtime behaviour was in question, run. -Scratch projects multi-target `netstandard2.0;net472;net8.0`, `LangVersion 14`, referencing the real -`src/RESPite` and `src/StackExchange.Redis`. +Everything above marked "verified" was compiled and, where runtime behaviour was in question, run — +first in throwaway scratch projects multi-targeting `netstandard2.0;net472;net8.0` against the real +`src/RESPite` and `src/StackExchange.Redis`, and then in the in-repo spike of §9. Generated frames were validated by parsing them back with RESPite's own `RespReader`, including `DemandEnd()` so the frame must be exactly consumed — over- and under-run both fail. From 16fb63d29620c55194940c07272d640b5147fb9c Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 17:45:01 +0100 Subject: [PATCH 014/360] AppendLiteral takes a string; the ban does not leak Checks what the compiler actually passes for literal segments, and whether a differently-typed overload could bypass the [Obsolete] ban. It is always a string: an AppendLiteral(ReadOnlySpan) overload does bind, via the implicit string-to-span conversion, but AppendLiteral(ReadOnlySpan) does not - CS1503, cannot convert from string. So there is no route to having the compiler hand over UTF8 bytes for a literal, which is one more argument for banning literals rather than encoding them at runtime. The ban is robust: with both an obsolete string overload and a non-obsolete ReadOnlySpan one, the obsolete overload still wins with CS0619 rather than silently binding to the span. Exact match beats the span conversion, and [Obsolete] is a post-resolution diagnostic rather than a candidate filter. The corollary is the guard rail: the ban depends on the obsolete string overload continuing to exist. Delete it, leaving only a span overload, and literal segments silently start binding again. --- design/interpolated-resp-writer.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 312e76c5c..57e58e789 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -83,6 +83,28 @@ and no back-fill. stays a hole. Confirmed both at runtime and by the fact that it compiles against a handler that has no `AppendLiteral` at all. +**The compiler always passes a `string`, never u8.** Literal segments arrive as a `string` constant; +there is no route to having the compiler hand you UTF8 bytes for them. Verified across the three +plausible overload shapes: + +| `AppendLiteral` overload | Binds to a literal segment? | +| --- | --- | +| `string` | yes — this is what the compiler passes | +| `ReadOnlySpan` | yes, via the implicit `string` → span conversion | +| `ReadOnlySpan` | **no** — `CS1503: cannot convert from 'string' to 'System.ReadOnlySpan'` | + +The `ReadOnlySpan` form buys nothing (the argument is a constant `string` either way), and the +absence of a `u8` route is one more reason to ban literals rather than encode them at runtime. + +**The ban does not leak.** With *both* an obsolete `AppendLiteral(string)` and a non-obsolete +`AppendLiteral(ReadOnlySpan)`, the **obsolete one still wins** — `CS0619`, not a silent bind to +the span overload. Exact match beats the span conversion, and `[Obsolete]` is a post-resolution +diagnostic rather than a candidate filter, so adding overloads cannot bypass the ban. + +The corollary is the guard rail worth knowing: the ban depends on the obsolete `string` overload +continuing to *exist*. Delete it and leave only a span overload, and literal segments silently start +binding again. + **Non-interpolated strings do *not* bind to the handler.** If a `string` overload exists alongside, `Write(buf, "plain literal")` silently takes it while `Write(buf, $"GET {key}")` takes the handler. Either don't provide a `string` overload, or accept that callers must write `$"PING"`. From a3387e36283728b1ef6c690777a12a191ba5b3a5 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 17:45:53 +0100 Subject: [PATCH 015/360] Cite the language spec; record the rejected format-specifier route Adds the C# 10 "Improved Interpolated Strings" feature spec as the reference, and quotes it where it settles a question rather than leaving findings as bare observation. The most useful quote is in 2.1: "The argument list Al is constructed with one value parameter of type string. Traditional method invocation resolution is performed with method group Ml and argument list Al." That single rule predicts all three AppendLiteral results - ReadOnlySpan binds because the conversion is applicable, ReadOnlySpan does not because it is not, and the obsolete string overload wins because exact match beats the span conversion and [Obsolete] applies after resolution. Three observations collapse to one rule. Also cites the spec for the empty string meaning the receiver, the optional trailing out bool, the bool short-circuit ("logically anded with all preceding Fax calls"), and the alignment/format AppendFormatted shapes. Notes the spec places no requirement on where the attributes are declared, which is what makes the down-level polyfill legitimate rather than incidental. Records the format-specifier route ({value:R}) that was tried and dropped: it works, but the specifier is only checked at runtime so a typo falls through silently, it cannot carry ArgCount, and once raw fragments are a distinct type the marker is redundant. --- design/interpolated-resp-writer.md | 51 +++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 57e58e789..c5785b09e 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -25,6 +25,11 @@ This is intended to **replace the writer half of the `marc/respite` v3 PoC spike ## 1. Is the handler pattern usable down-level? +**Spec:** [Improved Interpolated Strings](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/improved-interpolated-strings.md) +(C# 10 feature spec). Quoted below where it settles a question; the empirical checks agree with it +throughout. Notably it imposes **no requirement that the attributes come from corelib** — they are +recognised by name — which is what makes the polyfill legitimate rather than a trick that happens to work. + **Yes, with no runtime support at all.** Interpolated string handlers are 100% compiler lowering, and the marker attributes are matched *by full name*, so declaring them `internal` in our own source works exactly like the existing `SkipLocalsInit` (`src/RESPite/Shared/SkipLocalsInit.cs`) and @@ -51,6 +56,18 @@ Verified by compiling across `netstandard2.0` / `net472` / `net8.0`: `net461` was not tested (no reference assemblies to hand) but uses the same compiler path; the only dependency is `ReadOnlySpan`, which RESPite already has there via `System.Memory`. +The spec text behind three of those rows, since they shape the design elsewhere: + +- **Constructor** — *"The first two arguments are integer constants, representing the literal length of + `i`, and the number of interpolation components in `i`, respectively."* Extra parameters come from + `InterpolatedStringHandlerArgumentAttribute`, and the trailing `out bool` is optional: *"If no + applicable constructors were found, step 3 is retried, removing the final `bool` parameter."* +- **Short-circuiting** — *"If `Fax` returns a `bool`, the result is logically anded with all preceding + `Fax` calls."* So `bool` returns genuinely stop later holes being evaluated (§2.4 rejects this for the + disabled-command case, which must throw rather than quietly truncate). +- **`AppendFormatted` shapes** — the value by itself; plus an `int alignment` when the hole carries + `,N`; plus a `string format` when it carries `:F`. See §2.3 for why the `format` route was not used. + **`InlineArray` is the one thing that is *not* polyfillable** — it needs .NET 8+ runtime layout support, and down-level the attribute is inert, silently giving a one-element struct. Use `stackalloc` at the call site or `fixed` buffers instead. @@ -83,9 +100,14 @@ and no back-fill. stays a hole. Confirmed both at runtime and by the fact that it compiles against a handler that has no `AppendLiteral` at all. -**The compiler always passes a `string`, never u8.** Literal segments arrive as a `string` constant; -there is no route to having the compiler hand you UTF8 bytes for them. Verified across the three -plausible overload shapes: +**The compiler always passes a `string`, never u8.** The spec is explicit, and one rule accounts for +every case below: + +> The argument list `Al` is constructed with one value parameter of type `string`. Traditional method +> invocation resolution is performed with method group `Ml` and argument list `Al`. + +So the argument is *always* a `string`, and binding is then ordinary overload resolution. Verified across +the three plausible overload shapes: | `AppendLiteral` overload | Binds to a literal segment? | | --- | --- | @@ -93,13 +115,18 @@ plausible overload shapes: | `ReadOnlySpan` | yes, via the implicit `string` → span conversion | | `ReadOnlySpan` | **no** — `CS1503: cannot convert from 'string' to 'System.ReadOnlySpan'` | -The `ReadOnlySpan` form buys nothing (the argument is a constant `string` either way), and the -absence of a `u8` route is one more reason to ban literals rather than encode them at runtime. +All three follow from the rule: `string`→`ReadOnlySpan` is an applicable conversion, `string`→ +`ReadOnlySpan` is not, and there is no step at which the compiler would UTF8-encode. The +`ReadOnlySpan` form buys nothing (the argument is a constant `string` either way), and the absence +of a `u8` route is one more reason to ban literals rather than encode them at runtime. + +("Traditional method invocation resolution" is also why the ban holds: see below.) **The ban does not leak.** With *both* an obsolete `AppendLiteral(string)` and a non-obsolete `AppendLiteral(ReadOnlySpan)`, the **obsolete one still wins** — `CS0619`, not a silent bind to -the span overload. Exact match beats the span conversion, and `[Obsolete]` is a post-resolution -diagnostic rather than a candidate filter, so adding overloads cannot bypass the ban. +the span overload. That is the "traditional method invocation resolution" rule again: the exact `string` +match beats the span conversion, and `[Obsolete]` is a post-resolution diagnostic rather than a candidate +filter. Adding overloads therefore cannot bypass the ban. The corollary is the guard rail worth knowing: the ban depends on the obsolete `string` overload continuing to *exist*. Delete it and leave only a span overload, and literal segments silently start @@ -169,6 +196,13 @@ assertion. Ship `Resp()` and `Resp(int argCount)` as **separate overloads**, not one optional parameter — adding an optional parameter later is a binary break (AGENTS.md). +**Considered and dropped: a format specifier.** The compiler supports `{value:R}`, binding to +`AppendFormatted(T value, string format)`, so a hole could have been *marked* raw rather than typed raw. +It was tried and works, but loses on three counts: the specifier is only checked at runtime, so `{x:r}` +or `{x:Raw}` compiles and silently falls through to whatever the default branch does; it cannot carry +`ArgCount`; and once raw fragments are a distinct type the marker is redundant anyway. The typed wrapper +gives compile-time dispatch and a place to put the arg count, so `:R` earns nothing. + ### 2.4 `RedisCommand` and CommandMap The command must be a `RedisCommand` so it routes through `CommandMap` (renaming/disabling per server @@ -222,7 +256,8 @@ Prefer the argument form. Its terseness advantage is small and the receiver is t ### 3.3 The receiver, and a context object `[InterpolatedStringHandlerArgument("")]` passes the **receiver** of the call into the handler's -constructor. Verified working in every shape that matters — concrete receiver, receiver via an +constructor — per the spec, *"The empty string is matched to the receiver of `M1`."* Verified working in +every shape that matters — concrete receiver, receiver via an interface, implicit `this` from inside the type, an `object`-typed ctor parameter, and extension methods (where the receiver is the first parameter, so `nameof(db)` rather than `""`). From 1e6f01ae9197783e530da582a961816c847c2fcf Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 17:50:35 +0100 Subject: [PATCH 016/360] Idea: relax the literal ban to allow exactly one space $"{RedisCommand.SET} {key} {value}" reads as SET key value - the form every Redis doc and redis-cli session uses - where the unspaced version does not. It does not cost the *N constant, which was the ban's main justification: spaces are literal segments rather than holes, so formattedCount is unchanged at 3 either way; only literalLength moves, which just nudges the buffer size hint. Confirmed empirically. Nor does it cost anything measurable: 0.45 ns per space against an 8.6 ns baseline of pure handler machinery in a loop doing no buffer work, where a real render is tens to hundreds of ns. Both spellings render byte-identically since the space is discarded, so cache identity is unaffected. The real cost is the compile-time guarantee: AppendLiteral can no longer be [Obsolete(error: true)], so a stray literal becomes an exception on first execution rather than a build break. The analyzer can restore that and is already required for the Resp.Raw rules, so it is one more rule on existing machinery, with the runtime check demoted to a backstop. Records the three cases the analyzer must cover because a runtime check handles them badly - two spaces being visually identical to one, leading and trailing spaces also satisfying "exactly one space", and formatters normalising whitespace - and notes that this retires the "ban does not leak" property, since with no obsolete overload there is no overload-resolution argument left to lean on. --- design/interpolated-resp-writer.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index c5785b09e..bbff20338 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -132,6 +132,43 @@ The corollary is the guard rail worth knowing: the ban depends on the obsolete ` continuing to *exist*. Delete it and leave only a span overload, and literal segments silently start binding again. +#### Idea: relax the ban to allow exactly one space + +`$"{RedisCommand.SET} {key} {value}"` reads as `SET key value` — the form every Redis doc and +`redis-cli` session uses — where `$"{RedisCommand.SET}{key}{value}"` does not. Permitting a single +space, discarded at runtime, buys that. + +**It does not cost the `*N` constant**, which is the ban's main justification. Spaces are literal +segments, not holes, so `formattedCount` is unchanged; only `literalLength` moves, which merely nudges +the buffer size hint. Measured: + +``` +tight : args=3 literals=0 formattedCount=3 literalLength=0 +spaced : args=3 literals=2 formattedCount=3 literalLength=2 +``` + +**Nor does it cost anything measurable.** 0.45 ns per space, against an 8.6 ns baseline of pure handler +machinery in a synthetic loop that does no buffer work at all; a real render is tens to hundreds of ns, +and the operation around it is orders beyond that. Both spellings also render byte-identically, since +the space is discarded — so cache identity (§6.2) is unaffected. + +**What it does cost is the compile-time guarantee.** `AppendLiteral` can no longer be +`[Obsolete(error: true)]`, so `$"SET{key}"` becomes an exception on first execution rather than a build +break. The analyzer (§7) can restore that, and it is already required for the `Resp.Raw` rules, so this +is one more rule on existing machinery rather than new machinery — with the runtime check demoted to a +backstop. + +Three cases the analyzer must cover, because a runtime check handles them badly: + +- **Two spaces look exactly like one.** Throwing on `$"{a} {b}"` is correct but arrives at the worst + moment, and the defect is invisible on the page. +- **Position.** "Exactly one space" also permits `$" {a}"` and `$"{a} "`; the rule wanted is *between* + holes. +- **Formatters.** Nothing stops a tool normalising whitespace inside an interpolated string. + +Note this also retires the "ban does not leak" property below: with no obsolete overload there is no +overload-resolution argument to lean on, and enforcement rests on the analyzer plus the runtime check. + **Non-interpolated strings do *not* bind to the handler.** If a `string` overload exists alongside, `Write(buf, "plain literal")` silently takes it while `Write(buf, $"GET {key}")` takes the handler. Either don't provide a `string` overload, or accept that callers must write `$"PING"`. From 2fb0307c782f41f8bec1aa00acd3d16b8230a663 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 18:58:01 +0100 Subject: [PATCH 017/360] Add Compose/Execute(ref cmd) for optional and contextual arguments The spike only had the single-expression form; the deferred shape had been tried in scratch and never landed. Adds RespContext.Compose, which carries [InterpolatedStringHandlerArgument("")] and returns the handler: var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); if (withTtl) { cmd.AppendFormatted(ex); cmd.AppendFormatted(ttl); } using var frame = ctx.Execute(ref cmd); Execute(ref cmd) needs no new overload, and could not have one since the attribute does not change the signature: it binds to the same Execute, because the interpolated-string-handler conversion applies only when the argument IS an interpolated string. Passing a variable by ref is an ordinary argument and the attribute is ignored. Verified rather than assumed. Six new tests, 35 total, green on net10.0 and net8.0 with net481 compiling and Release analyzers clean: - ComposeThenConditionallyAppend, a theory over the four ttl/nx combinations. - ComposedKeysStillTrackAndRoute - a key appended after the interpolation is still marked and folded into the slot. - ComposedHeaderGrowsWithLateArguments - three arguments at the call site and twelve by execution, which pins the case a compile-time *3 would have framed as a corrupt command. Documents the ownership constraint: Execute takes the buffer on success, but the window between Compose and Execute is arbitrary user code, and CS1657 means the handler cannot be held in a using while also being passed by ref - so a throwing window needs try/finally, not using. --- design/interpolated-resp-writer.md | 34 ++++++++++- .../Interpolated/RespContext.cs | 22 +++++++ .../InterpolatedWriterUnitTests.cs | 60 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index bbff20338..96db2caec 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -418,6 +418,36 @@ constant. **Gotcha:** `using var` cannot be passed by `ref` (CS1657). Mark resolution members `readonly` so `in` works, or callers are forced into `try`/`finally`. +### 4.1 `Compose` / `Execute(ref cmd)` — the shape for optional arguments + +Implemented in the spike (§9): + +```csharp +var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); +if (withTtl) { cmd.AppendFormatted(ex); cmd.AppendFormatted(ttl); } +using var frame = ctx.Execute(ref cmd); +``` + +`Compose` carries `[InterpolatedStringHandlerArgument("")]` and simply returns the handler. +**`Execute(ref cmd)` needs no new overload** — and could not have one, since the attribute does not change +the signature: it binds to the same `Execute`, because the interpolated-string-handler conversion applies +only when the argument *is* an interpolated string. Passing a real variable by `ref` is an ordinary +argument and the attribute is ignored. Verified. + +The trade is the one from §4: the argument count is only known at `Complete`, so `*N` is back-filled +rather than a compile-time constant. `ComposedHeaderGrowsWithLateArguments` pins the case that would +otherwise be silently wrong — three arguments at the call site, twelve by execution, so a compile-time +`*3` would have framed a corrupt command. + +Keys appended after the interpolation still track and route normally +(`ComposedKeysStillTrackAndRoute`): the second key of an `SMOVE` arrives via `AppendFormatted` and is +still marked and folded into the slot. + +**Ownership:** `Execute` takes the buffer on success, so there is nothing to dispose afterwards. But the +window between `Compose` and `Execute` is arbitrary user code, and CS1657 means the handler cannot be +held in a `using` while also being passed by `ref` — so a throwing window needs `try`/`finally` calling +`Dispose`, not `using`. + --- ## 5. Key and slot accumulation @@ -917,7 +947,7 @@ A working spike, all `internal`, so there is no public API commitment yet. | `src/StackExchange.Redis/Interpolated/RespContext.cs` | CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, CancellationToken; `With*` clones; `Execute` | | `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | | `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | -| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 26 tests | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 35 tests | Green on net10.0 and net8.0; net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. @@ -939,6 +969,8 @@ What the tests pin, grouped by the section they belong to: - **Cache identity (§6.2)** — `DatabaseIsNotPartOfTheRenderedFrame`. - **Cancellation (§3.3)** — `CancellationIsObservedAndTheBufferIsReturned`, `CancellationTokenFlowsThroughWithClones`. +- **Deferred composition (§4.1)** — `ComposeThenConditionallyAppend` (theory over the four + ttl/nx combinations), `ComposedKeysStillTrackAndRoute`, `ComposedHeaderGrowsWithLateArguments`. **What the spike does not do:** it stops at "the right bytes were rendered, and we know which arguments were keys". Nothing dispatches, nothing caches, and the read half (§8.4) is untouched — so the claim diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 283029e08..4f3ed512e 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -102,6 +102,28 @@ public RespContext WithChannelPrefix(RedisChannel channelPrefix) /// A real Execute would go on to dispatch the frame; this spike stops at "the right bytes were /// rendered, and we know which arguments were keys". /// + /// + /// Begin a command whose argument list is not fully known at the call site, for optional or + /// contextual arguments: + /// + /// var cmd = ctx.Compose($"{RedisCommand.SET} {key} {value}"); + /// if (withTtl) { cmd.AppendFormatted(RespLiterals.EX); cmd.AppendFormatted(ttl); } + /// using var frame = ctx.Execute(ref cmd); + /// + /// + /// + /// The argument count is then only known at , so the + /// *N header is back-filled rather than written as a compile-time constant. Prefer the + /// single-expression form for fixed-arity commands. + /// + /// NOTE: the handler cannot be held by using, because a using variable cannot be passed + /// by ref (CS1657). If the window between Compose and Execute can throw, use try/finally and + /// call . + /// + /// + public RespCommandHandler Compose([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) + => handler; + public RespFrame Execute([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) { if (CancellationToken.IsCancellationRequested) diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 52989ed2a..641794532 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -342,4 +342,64 @@ public void LargePayloadForcesBufferGrowthMidBuild() // the reserved prologue and the key offset must survive the regrow Assert.Equal(new[] { "k" }, Keys(frame)); } + + // ---- deferred composition: optional / contextual arguments ------------------------------------- + + [Theory] + [InlineData(false, false, "SET|k|v")] + [InlineData(true, false, "SET|k|v|EX|300")] + [InlineData(false, true, "SET|k|v|NX")] + [InlineData(true, true, "SET|k|v|EX|300|NX")] + public void ComposeThenConditionallyAppend(bool withTtl, bool withNx, string expected) + { + var ctx = new RespContext(); + var cmd = ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + try + { + if (withTtl) + { + cmd.AppendFormatted((RedisValue)"EX"); + cmd.AppendFormatted((RedisValue)300); + } + + if (withNx) cmd.AppendFormatted((RedisValue)"NX"); + + using var frame = ctx.Execute(ref cmd); + Assert.Equal(expected, string.Join("|", Parse(frame.Span))); + Assert.Equal(expected.Split('|').Length, frame.ArgCount); + } + catch + { + cmd.Dispose(); // Execute takes ownership on success; this covers the throwing window + throw; + } + } + + [Fact] + public void ComposedKeysStillTrackAndRoute() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + var cmd = ctx.Compose($"{RedisCommand.SMOVE}{(RedisKey)"{u}:src"}"); + cmd.AppendFormatted((RedisKey)"{u}:dst"); // second key arrives AFTER the interpolation + cmd.AppendFormatted((RedisValue)"m"); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal(new[] { "SMOVE", "{u}:src", "{u}:dst", "m" }, Parse(frame.Span)); + Assert.Equal(new[] { "{u}:src", "{u}:dst" }, Keys(frame)); + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"{u}:src"), frame.Slot); + } + + [Fact] + public void ComposedHeaderGrowsWithLateArguments() + { + // 3 args at the call site, 12 by the time it is executed: '*3' would have been wrong + var ctx = new RespContext(); + var cmd = ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + for (int i = 0; i < 9; i++) cmd.AppendFormatted((RedisValue)i); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal(12, frame.ArgCount); + Assert.StartsWith("*12\r\n", Encoding.UTF8.GetString(frame.Span.ToArray())); + Assert.Equal(new[] { "k" }, Keys(frame)); // offset survived the two-digit header + } } From 2409ab172b60481bba82b6c63da35d97e8a44cdb Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 19:00:37 +0100 Subject: [PATCH 018/360] Add command-as-argument and no-interpolation initializers Section 3.1 documented the argument form - receiver plus a parameter - as preferred, but the spike never implemented it. Adds three initializer forms, all returning the builder: ctx.Compose($"{cmd}{a}{b}") command as the first hole ctx.Compose(cmd, $"{a}{b}") command as a real argument (preferred) ctx.Compose(cmd, argHint) no interpolated part at all The second passes ("", nameof(command)) so the receiver AND the command reach the constructor, which lets the command map be consulted before the buffer is rented. Execute gains the matching overload. The third covers the variadic case - DEL over a runtime-sized key array - where there is no fixed prefix to interpolate at all; argHint only sizes the initial rent and appending beyond it simply grows the buffer. Four new tests, 39 total, green on net10.0 and net8.0 with Release analyzers clean. Also records the right justification for abandoning a buffer when an interpolation throws: DefaultInterpolatedStringHandler does exactly the same, renting from ArrayPool.Shared and abandoning the rental, because the compiler emits no try/finally around the append sequence. Broken usage dumping an incomplete buffer is the established behaviour of the pattern, not something this design has to justify from first principles - so validating the command before renting is a tidiness win rather than a correctness one, and not worth contorting the API for. --- design/interpolated-resp-writer.md | 44 +++++++++++++--- .../Interpolated/RespCommandHandler.cs | 29 +++++++++++ .../Interpolated/RespContext.cs | 37 ++++++++++++++ .../InterpolatedWriterUnitTests.cs | 51 +++++++++++++++++++ 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 96db2caec..79d8f8453 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -429,6 +429,25 @@ using var frame = ctx.Execute(ref cmd); ``` `Compose` carries `[InterpolatedStringHandlerArgument("")]` and simply returns the handler. + +Three initializer forms exist, all returning the builder: + +| Form | When | +| --- | --- | +| `ctx.Compose($"{cmd}{a}{b}")` | command as the first hole | +| `ctx.Compose(cmd, $"{a}{b}")` | **preferred** — command as a real argument (§3.1), so the map is consulted before the rent | +| `ctx.Compose(cmd, argHint)` | no interpolated part at all, for a fully dynamic argument list | + +The last is for the variadic case — `DEL` over a runtime-sized key array, where there is no fixed prefix +to interpolate: + +```csharp +var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); +foreach (var key in keys) cmd.AppendFormatted(key); +using var frame = ctx.Execute(ref cmd); +``` + +`argHint` only sizes the initial rent; it is not a promise, and appending more simply grows the buffer. **`Execute(ref cmd)` needs no new overload** — and could not have one, since the attribute does not change the signature: it binds to the same `Execute`, because the interpolated-string-handler conversion applies only when the argument *is* an interpolated string. Passing a real variable by `ref` is an ordinary @@ -759,15 +778,22 @@ Execute(h, handler); // consumer's using/try-finally only starts HERE ``` So on a throw in that window there is no handler for the consumer to dispose. Dropping the buffer is -the only available behaviour, and it is fine: `MemoryTrackedPool` is a thin wrapper over -`ArrayPool.Shared` (`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, -so a dropped buffer is simply garbage. +the only available behaviour, and that is **accepted, not merely tolerated**: `DefaultInterpolatedStringHandler` +does exactly the same — it rents from `ArrayPool.Shared` and abandons the rental if an +interpolation throws, because the compiler emits no `try`/`finally` around the append sequence. Broken +usage dumping an incomplete buffer is the established behaviour of the pattern. + +It is also harmless here: `MemoryTrackedPool` is a thin wrapper over `ArrayPool.Shared` +(`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, so a dropped buffer is +simply garbage. Two notes: -- **Validate the command before renting.** A CommandMap-disabled command is both the most likely throw - here and the most likely to *repeat* (config-driven, so every call). In the argument form `command` - reaches the constructor, so it can be checked before the rent. +- **Validate the command before renting** where it is free to do so. A CommandMap-disabled command is + both the most likely throw here and the most likely to *repeat*, being configuration-driven. The + command-as-argument form (§4.1) gets this for nothing, since `command` reaches the constructor. This is + a tidiness win rather than a correctness one — see the `DefaultInterpolatedStringHandler` precedent + above — so it is not worth contorting the API for. - **A bounded custom pool would invalidate this.** Dropping into `ArrayPool.Shared` is free because Shared doesn't track; dropping a chunk from a bounded free-list permanently removes capacity and silently degrades to allocating every time. `CycleBuffer.AppendOrRecycle(segment, maxDepth: 2)` shows @@ -947,7 +973,7 @@ A working spike, all `internal`, so there is no public API commitment yet. | `src/StackExchange.Redis/Interpolated/RespContext.cs` | CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, CancellationToken; `With*` clones; `Execute` | | `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | | `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | -| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 35 tests | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 39 tests | Green on net10.0 and net8.0; net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. @@ -970,7 +996,9 @@ What the tests pin, grouped by the section they belong to: - **Cancellation (§3.3)** — `CancellationIsObservedAndTheBufferIsReturned`, `CancellationTokenFlowsThroughWithClones`. - **Deferred composition (§4.1)** — `ComposeThenConditionallyAppend` (theory over the four - ttl/nx combinations), `ComposedKeysStillTrackAndRoute`, `ComposedHeaderGrowsWithLateArguments`. + ttl/nx combinations), `ComposedKeysStillTrackAndRoute`, `ComposedHeaderGrowsWithLateArguments`, + `ComposeWithCommandArgument`, `ExecuteWithCommandArgument`, `ComposeWithNoInterpolationAtAll`, + `DisabledCommandThrowsFromBothInitializerForms`. **What the spike does not do:** it stops at "the right bytes were rendered, and we know which arguments were keys". Nothing dispatches, nothing caches, and the read half (§8.4) is untouched — so the claim diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index c10a16cf8..440fed3b5 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -41,6 +41,35 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _hasCommand = false; } + /// + /// Initialize with the command supplied as a real argument rather than as the first hole, so that + /// the interpolation carries only the arguments. + /// + /// + /// Preferred over the command-as-a-hole form: the command map is consulted before the buffer + /// is rented, so a disabled command - the most likely throw in this window, and the most likely to + /// repeat, being configuration-driven - drops nothing on the floor. See + /// design/interpolated-resp-writer.md section 6.5. + /// + public RespCommandHandler(int literalLength, int formattedCount, RespContext context, RedisCommand command) + { + // resolve FIRST: this throws before anything is rented + var resp = context.CommandMap.GetResp(command); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + + _context = context; + _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + resp.Length + literalLength + (formattedCount * 24)); + _offset = HeaderMax; + _slot = ServerSelectionStrategy.NoSlot; + _keyMarks = 0; + + resp.CopyTo(_buffer.AsSpan(_offset)); + _offset += resp.Length; + _hasCommand = true; + _args = 1; + _argIndex = 1; + } + [Obsolete("Every part must be a hole, so that the argument count is known at compile time; write $\"{RedisCommand.SET}{key}{value}\", not $\"SET{key}{value}\".", error: true)] public void AppendLiteral(string value) => throw new NotSupportedException(); diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 4f3ed512e..a4c1c682f 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -124,6 +124,43 @@ public RespContext WithChannelPrefix(RedisChannel channelPrefix) public RespCommandHandler Compose([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) => handler; + /// + /// As , but with the command supplied as a real + /// argument rather than as the first hole: + /// + /// var cmd = ctx.Compose(RedisCommand.SET, $"{key}{value}"); + /// + /// + /// + /// ("", nameof(command)) passes the receiver and the command into the handler's + /// constructor, which lets the command map be consulted before the buffer is rented. + /// + public RespCommandHandler Compose( + RedisCommand command, + [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) + => handler; + + /// + /// Initialize a builder with no interpolated part at all, for a fully dynamic argument list: + /// + /// var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); + /// foreach (var key in keys) cmd.AppendFormatted(key); + /// using var frame = ctx.Execute(ref cmd); + /// + /// + /// The command to issue. + /// Expected number of arguments, used only to size the initial rent. + public RespCommandHandler Compose(RedisCommand command, int argHint = 0) + => new(0, argHint < 0 ? 0 : argHint, this, command); + + /// + /// As , with the command as a real argument. + /// + public RespFrame Execute( + RedisCommand command, + [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) + => Execute(ref handler); + public RespFrame Execute([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) { if (CancellationToken.IsCancellationRequested) diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 641794532..0aaa232ad 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -402,4 +402,55 @@ public void ComposedHeaderGrowsWithLateArguments() Assert.StartsWith("*12\r\n", Encoding.UTF8.GetString(frame.Span.ToArray())); Assert.Equal(new[] { "k" }, Keys(frame)); // offset survived the two-digit header } + + // ---- initializing with the command as a real argument ----------------------------------------- + + [Fact] + public void ComposeWithCommandArgument() + { + var ctx = new RespContext(); + var cmd = ctx.Compose(RedisCommand.SET, $"{(RedisKey)"k"}{(RedisValue)"v"}"); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal(new[] { "SET", "k", "v" }, Parse(frame.Span)); + Assert.Equal(new[] { "k" }, Keys(frame)); + } + + [Fact] + public void ExecuteWithCommandArgument() + { + var ctx = new RespContext(serverType: ServerType.Cluster); + using var frame = ctx.Execute(RedisCommand.GET, $"{(RedisKey)"foo"}"); + + Assert.Equal(new[] { "GET", "foo" }, Parse(frame.Span)); + Assert.Equal(12182, frame.Slot); + } + + [Fact] + public void ComposeWithNoInterpolationAtAll() + { + // fully dynamic argument list - variadic DEL over a runtime-sized set of keys + var keys = new RedisKey[] { "a", "b", "c" }; + var ctx = new RespContext(); + var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); + foreach (var key in keys) cmd.AppendFormatted(key); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); + Assert.Equal(4, frame.ArgCount); + Assert.True(frame.KeysNeedScan); // three keys exceeds the two inline offsets + } + + [Fact] + public void DisabledCommandThrowsFromBothInitializerForms() + { + // Note the command-as-argument form resolves the map before renting, while the hole form rents + // first and discovers it on the first append - but a throwing interpolation abandoning its buffer + // is accepted behaviour either way; DefaultInterpolatedStringHandler does exactly the same. + var map = CommandMap.Create(new Dictionary { ["get"] = null }); + var ctx = new RespContext(map); + + Assert.Throws(() => ctx.Compose(RedisCommand.GET, 0).Dispose()); + Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); + } } From 89bb0e3a4d1082e0acea9ef50cd431e8314ef933 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 19:03:08 +0100 Subject: [PATCH 019/360] Add a worked end-to-end example covering the realistic shapes InterpolatedWriterDemo covers the seven shapes a caller would actually use, each asserting the exact rendered frame so it reads as documentation rather than only as coverage: fixed arity, key plus value, keyspace isolation, optional arguments via Compose, a variadic command with a shared hash tag, cross-slot detection, and a prefixed channel. 46 tests total, green on net10.0 and net8.0, net481 compiles. The example also surfaced an open question now recorded: the spike folds a RedisChannel into the same slot as keys unconditionally. That suits sharded pub/sub, where SPUBLISH routes by slot, but is meaningless for plain PUBLISH, where it does not - and sharing one _slot field between keys and channels conflates two different things. RedisChannel carries a KeyRouted option that presumably ought to gate it. Visible in the example as the ChannelPrefix case reporting a slot for a plain PUBLISH. --- design/interpolated-resp-writer.md | 22 +++- .../InterpolatedWriterDemo.cs | 110 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 79d8f8453..b7ce66336 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -974,8 +974,23 @@ A working spike, all `internal`, so there is no public API commitment yet. | `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | | `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | | `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 39 tests | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | -Green on net10.0 and net8.0; net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. +Green on net10.0 and net8.0 (46 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` +clean. + +`InterpolatedWriterDemo` is the readable end-to-end example — each case asserts the exact rendered frame, +so it doubles as documentation of what the shapes produce: + +``` +FixedArity *2|$3|GET|$6|user:1| slot=10778 keys=user:1 +KeyAndValue *3|$3|SET|$6|user:1|$4|marc| slot=10778 keys=user:1 +KeyspaceIsolation *2|$3|GET|$9|t7:user:1| slot=13865 keys=t7:user:1 +OptionalArguments *5|$3|SET|$6|user:1|$4|marc|$2|EX|$3|300| slot=10778 keys=user:1 +VariadicWithSharedHashTag *4|$3|DEL|$5|{u}:a|$5|{u}:b|$5|{u}:c| slot=11826 keys= +CrossSlotIsDetected *3|$3|DEL|$5|alpha|$4|beta| slot=MULTI keys=alpha,beta +ChannelPrefix *3|$7|PUBLISH|$8|app:news|$2|hi| slot=5631 keys= +``` What the tests pin, grouped by the section they belong to: @@ -1008,6 +1023,11 @@ that the context can be threaded through to result processing is design, not dem ## 10. Open questions +- **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which + suits sharded pub/sub (`SPUBLISH`) but is meaningless for plain `PUBLISH`, where the channel does not + route by slot. `RedisChannel` carries a `KeyRouted` option (`Subscription.cs:83`) that presumably ought + to gate it, and sharing one `_slot` field between keys and channels conflates two different things. + Visible in the worked example as `ChannelPrefix` reporting `slot=5631` for a plain `PUBLISH`. - **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit cursor by its arg count, not by 1. Either forbid keys in `Raw` (rule 5) or have `Raw` carry its own bitmap to shift and OR in. diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs new file mode 100644 index 000000000..87df1def5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs @@ -0,0 +1,110 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// A worked example of the experimental interpolated RESP writer, covering the shapes a caller would +/// actually use. Doubles as documentation: each case shows the call, the exact frame it renders, and the +/// routing/key metadata folded while writing. See design/interpolated-resp-writer.md. +/// +public class InterpolatedWriterDemo +{ + /// Render the frame with CRLF shown as '|', so expectations stay readable. + private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + private static string Keys(in RespFrame frame) + { + Span ranges = stackalloc KeyRange[2]; + var count = frame.TryGetKeys(ranges); + if (count < 0) return ""; + var parts = new string[count]; + for (int i = 0; i < count; i++) parts[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); + return string.Join(",", parts); + } + + private static readonly RespContext Cluster = new(serverType: ServerType.Cluster); + + [Fact] + public void FixedArity() + { + using var frame = Cluster.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + + Assert.Equal("*2|$3|GET|$6|user:1|", Frame(frame)); + Assert.Equal("user:1", Keys(frame)); + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"user:1"), frame.Slot); + } + + [Fact] + public void KeyAndValue() + { + using var frame = Cluster.Execute(RedisCommand.SET, $"{(RedisKey)"user:1"}{(RedisValue)"marc"}"); + + Assert.Equal("*3|$3|SET|$6|user:1|$4|marc|", Frame(frame)); + Assert.Equal("user:1", Keys(frame)); // the value is not a key, and is not marked as one + } + + [Fact] + public void KeyspaceIsolation() + { + var tenant = Cluster.WithKeyPrefix("t7:"); + using var frame = tenant.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + + Assert.Equal("*2|$3|GET|$9|t7:user:1|", Frame(frame)); + Assert.Equal("t7:user:1", Keys(frame)); + + // the slot follows the PREFIXED key, so tenants do not collide on a slot either + using var plain = Cluster.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + Assert.NotEqual(plain.Slot, frame.Slot); + } + + [Fact] + public void OptionalArguments() + { + var cmd = Cluster.Compose(RedisCommand.SET, $"{(RedisKey)"user:1"}{(RedisValue)"marc"}"); + cmd.AppendFormatted((RedisValue)"EX"); + cmd.AppendFormatted((RedisValue)300); + using var frame = Cluster.Execute(ref cmd); + + Assert.Equal("*5|$3|SET|$6|user:1|$4|marc|$2|EX|$3|300|", Frame(frame)); + Assert.Equal("user:1", Keys(frame)); + } + + [Fact] + public void VariadicWithSharedHashTag() + { + var keys = new RedisKey[] { "{u}:a", "{u}:b", "{u}:c" }; + var cmd = Cluster.Compose(RedisCommand.DEL, keys.Length); + foreach (var key in keys) cmd.AppendFormatted(key); + using var frame = Cluster.Execute(ref cmd); + + Assert.Equal("*4|$3|DEL|$5|{u}:a|$5|{u}:b|$5|{u}:c|", Frame(frame)); + Assert.Equal("", Keys(frame)); // beyond two keys the inline offsets give out + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"{u}:a"), frame.Slot); + } + + [Fact] + public void CrossSlotIsDetected() + { + var cmd = Cluster.Compose(RedisCommand.DEL, 2); + cmd.AppendFormatted((RedisKey)"alpha"); + cmd.AppendFormatted((RedisKey)"beta"); + using var frame = Cluster.Execute(ref cmd); + + Assert.Equal("*3|$3|DEL|$5|alpha|$4|beta|", Frame(frame)); + Assert.Equal(ServerSelectionStrategy.MultipleSlots, frame.Slot); + } + + [Fact] + public void ChannelPrefix() + { + var pub = Cluster.WithChannelPrefix(new RedisChannel("app:", RedisChannel.PatternMode.Literal)); + var channel = new RedisChannel("news", RedisChannel.PatternMode.Literal); + using var frame = pub.Execute(RedisCommand.PUBLISH, $"{channel}{(RedisValue)"hi"}"); + + Assert.Equal("*3|$7|PUBLISH|$8|app:news|$2|hi|", Frame(frame)); + Assert.Equal("", Keys(frame)); // a channel is not a key + } +} From 1da15350fd375a9fb183aa50ccf8cd81a706edc7 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 19:06:38 +0100 Subject: [PATCH 020/360] Allow a single space between holes Implements the relaxation that was recorded as an idea: literal text is still rejected, except a single space, which is discarded. ctx.Execute(RedisCommand.SET, $"{key} {value}") // ok ctx.Execute($"SET {key} {value}") // rejected ctx.Execute($"{cmd} {key}") // rejected, two spaces It earns its place on readability alone - the spaced form mirrors how the command is written everywhere else - and costs nothing that matters: spaces are literal segments rather than holes, so formattedCount is unchanged and the *N header stays a compile-time constant, at 0.45 ns per space. The cost is the compile-time guarantee. AppendLiteral can no longer be [Obsolete(error: true)], so enforcement is a runtime check plus the analyzer, which now has to carry the rule - two spaces look exactly like one on the page, and leading or trailing spaces satisfy "exactly one space" without being separators. Section 2.1 is rewritten to describe the implemented behaviour rather than the earlier hard ban, keeping the two findings from that revision that bear on the alternative: omitting AppendLiteral gives worse diagnostics than [Obsolete], and the obsolete ban did not leak past a span overload. Two new tests, 48 total; the worked examples now use the spaced form. --- design/interpolated-resp-writer.md | 71 ++++++++----------- .../Interpolated/RespCommandHandler.cs | 26 ++++++- .../InterpolatedWriterDemo.cs | 6 +- .../InterpolatedWriterUnitTests.cs | 27 +++++++ 4 files changed, 85 insertions(+), 45 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index b7ce66336..c2da69cb1 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -76,21 +76,20 @@ support, and down-level the attribute is inert, silently giving a one-element st ## 2. Shape -### 2.1 Literals are banned +### 2.1 Literals are rejected, except a single space -`AppendLiteral` is declared but marked `[Obsolete(..., error: true)]`, so every part of the command -must be a hole: +Every part of the command must be a hole, with one exception: a **single space**, which is discarded. ```csharp -// error CS0619: All parts must be holes: $"{RedisCommand.SET}{key}{value}" -$"SET{key}{value}" +ctx.Execute(RedisCommand.SET, $"{key} {value}") // ok - the space is discarded +ctx.Execute($"SET {key} {value}") // rejected - "SET " is not a separator +ctx.Execute($"{cmd} {key}") // rejected - two spaces ``` -Two options were compared. *Omitting* `AppendLiteral` also rejects literals, but produces a -confusing pair of diagnostics (`CS1061` plus a bogus `CS8941` "does not return void or bool"). -`[Obsolete]` produces one error carrying our own message. Use `[Obsolete]`. +The space earns its place on readability alone: `$"{RedisCommand.SET} {key} {value}"` mirrors how the +command is written everywhere else, and costs 0.45 ns (measured below). -**Why ban them:** with no literal segments, the compiler-supplied `formattedCount` *is* the argument +**Why reject the rest:** with no literal segments, the compiler-supplied `formattedCount` *is* the argument count, as a compile-time constant — so `*N\r\n` can be written in the constructor with no counting and no back-fill. @@ -122,23 +121,26 @@ of a `u8` route is one more reason to ban literals rather than encode them at ru ("Traditional method invocation resolution" is also why the ban holds: see below.) -**The ban does not leak.** With *both* an obsolete `AppendLiteral(string)` and a non-obsolete -`AppendLiteral(ReadOnlySpan)`, the **obsolete one still wins** — `CS0619`, not a silent bind to -the span overload. That is the "traditional method invocation resolution" rule again: the exact `string` -match beats the span conversion, and `[Obsolete]` is a post-resolution diagnostic rather than a candidate -filter. Adding overloads therefore cannot bypass the ban. +**Enforcement is a runtime check, and wants an analyzer.** An earlier revision marked +`AppendLiteral(string)` as `[Obsolete(..., error: true)]`, which made *any* literal a compile error — +strictly stronger, but incompatible with allowing the space. Two findings from that revision, recorded +because they bear on the alternative: -The corollary is the guard rail worth knowing: the ban depends on the obsolete `string` overload -continuing to *exist*. Delete it and leave only a span overload, and literal segments silently start -binding again. +- *Omitting* `AppendLiteral` also rejects literals, but produces a confusing pair of diagnostics + (`CS1061` plus a bogus `CS8941` "does not return void or bool") where `[Obsolete]` gives one error + carrying our own message. +- The obsolete ban did **not** leak: with both an obsolete `AppendLiteral(string)` and a non-obsolete + `AppendLiteral(ReadOnlySpan)`, the obsolete one still won (`CS0619`), because the exact `string` + match beats the span conversion and `[Obsolete]` applies after resolution. -#### Idea: relax the ban to allow exactly one space +Allowing the space trades that compile-time guarantee for readability, so the analyzer (§7) has to carry +the rule instead — in particular because **two spaces look exactly like one** on the page, and a runtime +throw arrives at the worst possible moment. It also has to reject leading and trailing spaces, which +satisfy "exactly one space" but are not separators. -`$"{RedisCommand.SET} {key} {value}"` reads as `SET key value` — the form every Redis doc and -`redis-cli` session uses — where `$"{RedisCommand.SET}{key}{value}"` does not. Permitting a single -space, discarded at runtime, buys that. +#### Why the space costs nothing -**It does not cost the `*N` constant**, which is the ban's main justification. Spaces are literal +**It does not cost the `*N` constant**, which is the main justification for rejecting literals. Spaces are literal segments, not holes, so `formattedCount` is unchanged; only `literalLength` moves, which merely nudges the buffer size hint. Measured: @@ -152,22 +154,9 @@ machinery in a synthetic loop that does no buffer work at all; a real render is and the operation around it is orders beyond that. Both spellings also render byte-identically, since the space is discarded — so cache identity (§6.2) is unaffected. -**What it does cost is the compile-time guarantee.** `AppendLiteral` can no longer be -`[Obsolete(error: true)]`, so `$"SET{key}"` becomes an exception on first execution rather than a build -break. The analyzer (§7) can restore that, and it is already required for the `Resp.Raw` rules, so this -is one more rule on existing machinery rather than new machinery — with the runtime check demoted to a -backstop. - -Three cases the analyzer must cover, because a runtime check handles them badly: - -- **Two spaces look exactly like one.** Throwing on `$"{a} {b}"` is correct but arrives at the worst - moment, and the defect is invisible on the page. -- **Position.** "Exactly one space" also permits `$" {a}"` and `$"{a} "`; the rule wanted is *between* - holes. -- **Formatters.** Nothing stops a tool normalising whitespace inside an interpolated string. - -Note this also retires the "ban does not leak" property below: with no obsolete overload there is no -overload-resolution argument to lean on, and enforcement rests on the analyzer plus the runtime check. +The cost is the compile-time guarantee, discussed above: enforcement moves to a runtime check plus the +analyzer. Formatters are a third hazard alongside the two already noted — nothing stops a tool +normalising whitespace inside an interpolated string. **Non-interpolated strings do *not* bind to the handler.** If a `string` overload exists alongside, `Write(buf, "plain literal")` silently takes it while `Write(buf, $"GET {key}")` takes the handler. @@ -973,10 +962,10 @@ A working spike, all `internal`, so there is no public API commitment yet. | `src/StackExchange.Redis/Interpolated/RespContext.cs` | CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, CancellationToken; `With*` clones; `Execute` | | `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | | `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | -| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 39 tests | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 41 tests | | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | -Green on net10.0 and net8.0 (46 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` +Green on net10.0 and net8.0 (48 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. `InterpolatedWriterDemo` is the readable end-to-end example — each case asserts the exact rendered frame, @@ -994,6 +983,8 @@ ChannelPrefix *3|$7|PUBLISH|$8|app:news|$2|hi| slot=5631 What the tests pin, grouped by the section they belong to: +- **Literals (§2.1)** — `SingleSpacesAreAllowedAndDiscarded` (spaced and unspaced render identical + bytes), `OtherLiteralsAreRejected` (two spaces, a hyphen, a leading command name). - **Framing** — `RendersCommandKeyAndValue`, `RendersExactBytes`, `MultiByteAndEmptyPayloadsRoundTrip`, `LargePayloadForcesBufferGrowthMidBuild` (forces a pool regrow *after* the prologue is reserved). - **Header back-fill (§4)** — `HeaderBackfillIsRightAligned`, theory over 1/9/10/120 extra arguments, so diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 440fed3b5..26378bd58 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -70,8 +70,30 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _argIndex = 1; } - [Obsolete("Every part must be a hole, so that the argument count is known at compile time; write $\"{RedisCommand.SET}{key}{value}\", not $\"SET{key}{value}\".", error: true)] - public void AppendLiteral(string value) => throw new NotSupportedException(); + /// + /// Literal text is rejected, with one exception: a single space, which is discarded. That keeps + /// $"{RedisCommand.SET} {key} {value}" readable - it mirrors how the command is written + /// everywhere else - without the space becoming an argument. + /// + /// + /// Rejecting literals is what makes the compiler-supplied formattedCount the argument count, + /// so the *N header can be a compile-time constant. A discarded space does not affect that: + /// spaces are literal segments, not holes. See design/interpolated-resp-writer.md section 2.1. + /// + /// This is a runtime check; the analyzer is expected to catch it at build time, which it must, since + /// two spaces look exactly like one. + /// + /// + public void AppendLiteral(string value) + { + if (value is not " ") ThrowNotSeparator(value); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNotSeparator(string value) => throw new ArgumentException( + $"Only a single space may separate arguments; every other part must be a hole. Saw \"{value}\". " + + "Write $\"{RedisCommand.SET} {key} {value}\", not $\"SET {key} {value}\".", + nameof(value)); public void AppendFormatted(RedisCommand value) { diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs index 87df1def5..f20f9042e 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs @@ -40,7 +40,7 @@ public void FixedArity() [Fact] public void KeyAndValue() { - using var frame = Cluster.Execute(RedisCommand.SET, $"{(RedisKey)"user:1"}{(RedisValue)"marc"}"); + using var frame = Cluster.Execute(RedisCommand.SET, $"{(RedisKey)"user:1"} {(RedisValue)"marc"}"); Assert.Equal("*3|$3|SET|$6|user:1|$4|marc|", Frame(frame)); Assert.Equal("user:1", Keys(frame)); // the value is not a key, and is not marked as one @@ -63,7 +63,7 @@ public void KeyspaceIsolation() [Fact] public void OptionalArguments() { - var cmd = Cluster.Compose(RedisCommand.SET, $"{(RedisKey)"user:1"}{(RedisValue)"marc"}"); + var cmd = Cluster.Compose(RedisCommand.SET, $"{(RedisKey)"user:1"} {(RedisValue)"marc"}"); cmd.AppendFormatted((RedisValue)"EX"); cmd.AppendFormatted((RedisValue)300); using var frame = Cluster.Execute(ref cmd); @@ -102,7 +102,7 @@ public void ChannelPrefix() { var pub = Cluster.WithChannelPrefix(new RedisChannel("app:", RedisChannel.PatternMode.Literal)); var channel = new RedisChannel("news", RedisChannel.PatternMode.Literal); - using var frame = pub.Execute(RedisCommand.PUBLISH, $"{channel}{(RedisValue)"hi"}"); + using var frame = pub.Execute(RedisCommand.PUBLISH, $"{channel} {(RedisValue)"hi"}"); Assert.Equal("*3|$7|PUBLISH|$8|app:news|$2|hi|", Frame(frame)); Assert.Equal("", Keys(frame)); // a channel is not a key diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 0aaa232ad..78e98cc1f 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -453,4 +453,31 @@ public void DisabledCommandThrowsFromBothInitializerForms() Assert.Throws(() => ctx.Compose(RedisCommand.GET, 0).Dispose()); Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); } + + // ---- the single-space relaxation --------------------------------------------------------------- + + [Fact] + public void SingleSpacesAreAllowedAndDiscarded() + { + var ctx = new RespContext(); + using var spaced = ctx.Execute($"{RedisCommand.SET} {(RedisKey)"k"} {(RedisValue)"v"}"); + using var tight = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + + // identical bytes: the space is a literal segment, not an argument + Assert.True(spaced.Span.SequenceEqual(tight.Span)); + Assert.Equal(3, spaced.ArgCount); + Assert.Equal(new[] { "SET", "k", "v" }, Parse(spaced.Span)); + Assert.Equal(new[] { "k" }, Keys(spaced)); + } + + [Fact] + public void OtherLiteralsAreRejected() + { + var ctx = new RespContext(); + + // two spaces look identical to one on the page; this is why the analyzer has to carry the rule + Assert.Throws(() => ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}").Dispose()); + Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}-{(RedisKey)"k"}").Dispose()); + Assert.Throws(() => ctx.Execute($"SET {(RedisKey)"k"}").Dispose()); + } } From 96d222d56d231a0a32dd39a3fbc61252731b9293 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:00:12 +0100 Subject: [PATCH 021/360] Authoring raw fragments as generated partial properties Records the idea of declaring a fragment and letting a generator emit it: [Resp] private static partial Resp Ex { get; } [Resp("foo", "bar")] private static partial Resp FooBar { get; } Partial properties are C# 13 and LangVersion 14 is repo-wide; verified compiling on netstandard2.0, net472 and net8.0, since like the rest of this they are pure compiler lowering. This is an existing pattern here rather than a new one: AsciiHashGenerator already does it with partial classes, emitting .HashCS and .U8 from an [AsciiHash] attribute. Partial properties are the tidier shape - one member rather than a nested type - and two AsciiHashAttribute conventions transfer directly: the token is inferred from the member name unless overridden, and the attribute is [Conditional("DEBUG")] so it evaporates from shipped metadata while the generator still sees it in source. The consequence worth noting is that it largely removes the need for the raw-fragment analyzer rules. Those exist to validate hand-written u8 - framing, matching length prefixes, uppercase tokens - and a generator emits all three correctly by construction. The rule becomes "don't hand-write these" rather than "validate what you hand-wrote", and it settles the cache-key canonicality requirement at the source, since the generator uppercases as AsciiHash already does. --- design/interpolated-resp-writer.md | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index c2da69cb1..a3a5d4705 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -222,6 +222,47 @@ assertion. Ship `Resp()` and `Resp(int argCount)` as **separate overloads**, not one optional parameter — adding an optional parameter later is a binary break (AGENTS.md). +#### Authoring raw fragments: generated partial properties + +Rather than hand-writing `u8` blobs, declare the fragment and let a generator emit it: + +```csharp +// what the author writes +[Resp] private static partial Resp Ex { get; } // token inferred: "EX" +[Resp("foo", "bar")] private static partial Resp FooBar { get; } // two tokens, ArgCount 2 + +// what the generator emits +private static partial Resp Ex => new("$2\r\nEX\r\n"u8); +private static partial Resp FooBar => new("$3\r\nFOO\r\n$3\r\nBAR\r\n"u8, 2); +``` + +Partial properties are C# 13, and `LangVersion 14` is repo-wide; verified compiling on +`netstandard2.0`/`net472`/`net8.0`, since like everything else here they are pure compiler lowering. + +**This is an existing pattern in the tree, not a new one.** `AsciiHashGenerator` already does it with +partial *classes*: + +```csharp +[AsciiHash("__keyspace@")] +private static partial class KeyspaceChannelPrefix { } // generator emits .HashCS and .U8 +``` + +Partial properties are simply the tidier shape — one member rather than a nested type. Two conventions +from `AsciiHashAttribute` transfer directly: the token is **inferred from the member name** unless the +attribute overrides it, and the attribute is `[Conditional("DEBUG")]` so it evaporates from shipped +metadata while the generator still sees it in source. + +**It largely removes the need for the raw-fragment analyzer rules (§7.1-3).** Those exist to validate +hand-written `u8`: framing, matching length prefixes, uppercase tokens. A generator emits all three +correctly *by construction* — there is nothing left to check. The rule becomes "don't hand-write these" +rather than "validate what you hand-wrote", which is both easier to enforce and impossible to get subtly +wrong. It also settles the canonicality requirement from §6.3 at the source: the generator uppercases, +as `AsciiHash` already does. + +The optional `argCount` defaulting to 1 is fine here, despite the binary-compat rule against optional +parameters — that rule is about *shipped public* API, and if the constructor stays internal with +`.Resp()` as the public factory (§2.3), the generated call site is inside the assembly. + **Considered and dropped: a format specifier.** The compiler supports `{value:R}`, binding to `AppendFormatted(T value, string format)`, so a hole could have been *marked* raw rather than typed raw. It was tried and works, but loses on three counts: the specifier is only checked at runtime, so `{x:r}` From 10d2f6eac7b14606518de9fcdea7fb9f2cd1a1f8 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:03:17 +0100 Subject: [PATCH 022/360] Spike the partial-property fragment pattern, without the generator Adds RespFragment - an already-framed run of one or more RESP bulk strings - plus the [Resp] marker attribute and an AppendFormatted overload, and shows the authoring pattern with both halves hand-written in a test: the declared partial property an author would write, and the body a generator would emit. Multi-token fragments turn out to be the dominant shape rather than an edge case. Container commands, whose first argument is a fixed subcommand token, account for around 140 call sites in src/: CONFIG 22, CLIENT 22, SCRIPT 16, XGROUP 14, PUBSUB 13, OBJECT 12, LATENCY 12, CLUSTER 11, SLOWLOG 10, MEMORY 10, XINFO 8. CLIENT SETINFO LIB-NAME is three tokens and MAXLEN ~ is two. So ArgCount is load-bearing: without it the handler's argument count silently disagrees with the frame, which the tests pin directly - a two-token fragment must produce *4 rather than *3. The overload advances both argument counters by ArgCount rather than by one, so a multi-token fragment does not shift the key-mark bit positions of arguments after it; there is a test with a key on either side of a two-token fragment. Five new tests, 53 total, green on net10.0 and net8.0 with net481 compiling and Release analyzers clean. --- design/interpolated-resp-writer.md | 11 +- .../Interpolated/RespCommandHandler.cs | 17 +++ .../Interpolated/RespFragment.cs | 48 +++++++ .../InterpolatedWriterFragmentTests.cs | 123 ++++++++++++++++++ 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespFragment.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index a3a5d4705..95c6f8c62 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -239,6 +239,13 @@ private static partial Resp FooBar => new("$3\r\nFOO\r\n$3\r\nBAR\r\n"u8, 2); Partial properties are C# 13, and `LangVersion 14` is repo-wide; verified compiling on `netstandard2.0`/`net472`/`net8.0`, since like everything else here they are pure compiler lowering. +**Multi-token fragments are not hypothetical — they are the dominant shape.** Container commands, whose +first argument is a fixed subcommand token, account for roughly 140 call sites in `src/`: `CONFIG` (22), +`CLIENT` (22), `SCRIPT` (16), `XGROUP` (14), `PUBSUB` (13), `OBJECT` (12), `LATENCY` (12), `CLUSTER` +(11), `SLOWLOG` (10), `MEMORY` (10), `XINFO` (8). `CLIENT SETINFO LIB-NAME` is three tokens; `MAXLEN ~` +in `XADD`/`XTRIM` is two. So `ArgCount` is load-bearing rather than defensive — without it the handler's +argument count silently disagrees with the frame. + **This is an existing pattern in the tree, not a new one.** `AsciiHashGenerator` already does it with partial *classes*: @@ -1004,9 +1011,11 @@ A working spike, all `internal`, so there is no public API commitment yet. | `src/StackExchange.Redis/Interpolated/RespCommandHandler.cs` | renders the frame, folds the slot, marks keys | | `src/StackExchange.Redis/Interpolated/RespFrame.cs` | rendered frame + slot + key marks + `KeyRange` | | `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 41 tests | +| `src/StackExchange.Redis/Interpolated/RespFragment.cs` | pre-framed token runs + the `[Resp]` marker | | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 5 tests; both halves of the partial-property pattern, hand-written | -Green on net10.0 and net8.0 (48 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` +Green on net10.0 and net8.0 (53 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. `InterpolatedWriterDemo` is the readable end-to-end example — each case asserts the exact rendered frame, diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 26378bd58..02922f170 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -152,6 +152,23 @@ public void AppendFormatted(RedisChannel value) _argIndex++; } + /// + /// Write an already-framed fragment verbatim. Note the argument counters advance by + /// , not by one, so a multi-token fragment does not shift the + /// key-mark bit positions of everything after it. + /// + public void AppendFormatted(RespFragment value) + { + DemandCommand(); + + var bytes = value.Bytes; + Ensure(bytes.Length); + bytes.CopyTo(_buffer.AsSpan(_offset)); + _offset += bytes.Length; + _args += value.ArgCount; + _argIndex += value.ArgCount; + } + public void AppendFormatted(RedisValue value) { DemandCommand(); diff --git a/src/StackExchange.Redis/Interpolated/RespFragment.cs b/src/StackExchange.Redis/Interpolated/RespFragment.cs new file mode 100644 index 000000000..481727429 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespFragment.cs @@ -0,0 +1,48 @@ +using System; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. An already-framed run of one or more RESP bulk strings, written verbatim. + /// + /// + /// + /// Intended for fixed tokens — EX, NX, and the subcommand of a container command such as + /// CONFIG GET or CLIENT SETINFO LIB-NAME. is why this is a type + /// rather than a bare : a fragment may be more than one argument, and + /// without it the handler's argument count would silently disagree with the frame. + /// + /// + /// These should be produced by a generator from a declared partial property rather than hand-written, + /// so that framing, length prefixes and upper-casing are correct by construction; see + /// design/interpolated-resp-writer.md section 2.3. + /// + /// + internal readonly ref struct RespFragment + { + public RespFragment(ReadOnlySpan bytes, int argCount = 1) + { + if (argCount < 1) throw new ArgumentOutOfRangeException(nameof(argCount)); + Bytes = bytes; + ArgCount = argCount; + } + + /// The pre-framed bytes, including every $len prefix and trailing CRLF. + public ReadOnlySpan Bytes { get; } + + /// How many RESP arguments contains. + public int ArgCount { get; } + } + + /// + /// EXPERIMENTAL SPIKE. Declares the tokens a generated property should emit. + /// Omit the tokens to infer a single token from the member name, as AsciiHashAttribute does. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + internal sealed class RespAttribute : Attribute + { + public RespAttribute(params string[] tokens) => Tokens = tokens; + + public string[] Tokens { get; } + } +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs new file mode 100644 index 000000000..1603f537e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Shows how fixed RESP tokens would be authored: a declared partial property, and the body a generator +/// would emit. Both halves are hand-written here - the point is the usage, not the generator. +/// See design/interpolated-resp-writer.md section 2.3. +/// +public class InterpolatedWriterFragmentTests +{ + // ---- half 1: what the AUTHOR writes ----------------------------------------------------------- + // The attribute is only needed when the token differs from the member name, or when the fragment + // spans more than one token. + + internal static partial class RespLiterals + { + /// The EX option of SET. Token inferred from the member name. + [Resp] + internal static partial RespFragment EX { get; } + + /// The subcommand of CONFIG GET - one argument, name would not suffice. + [Resp("get")] + internal static partial RespFragment ConfigGet { get; } + + /// SETINFO LIB-NAME, the two arguments following CLIENT. + [Resp("setinfo", "lib-name")] + internal static partial RespFragment SetInfoLibName { get; } + + /// MAXLEN ~, the two arguments preceding an XADD/XTRIM threshold. + [Resp("maxlen", "~")] + internal static partial RespFragment MaxLenApprox { get; } + } + + // ---- half 2: what the GENERATOR would emit ---------------------------------------------------- + // Tokens upper-cased for cache-key canonicality, exactly as CommandMap already does for commands. + + internal static partial class RespLiterals + { + internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); + + internal static partial RespFragment ConfigGet => new("$3\r\nGET\r\n"u8); + + internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nLIB-NAME\r\n"u8, 2); + + internal static partial RespFragment MaxLenApprox => new("$6\r\nMAXLEN\r\n$1\r\n~\r\n"u8, 2); + } + + private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + [Fact] + public void SingleTokenFragment() + { + var ctx = new RespContext(); + using var frame = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX} {(RedisValue)300}"); + + Assert.Equal("*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|", Frame(frame)); + Assert.Equal(5, frame.ArgCount); + } + + [Fact] + public void TwoTokenFragmentCountsAsTwoArguments() + { + // CLIENT SETINFO LIB-NAME StackExchange.Redis + var ctx = new RespContext(); + using var frame = ctx.Execute(RedisCommand.CLIENT, $"{RespLiterals.SetInfoLibName} {(RedisValue)"StackExchange.Redis"}"); + + Assert.Equal("*4|$6|CLIENT|$7|SETINFO|$8|LIB-NAME|$19|StackExchange.Redis|", Frame(frame)); + + // the fragment is TWO arguments: *4, not *3 - this is what ArgCount exists for + Assert.Equal(4, frame.ArgCount); + } + + [Fact] + public void MultiTokenFragmentDoesNotShiftKeyMarks() + { + // XADD key MAXLEN ~ 1000 * field value - the key precedes a two-token fragment + var ctx = new RespContext(serverType: ServerType.Cluster); + var cmd = ctx.Compose(RedisCommand.XADD, $"{(RedisKey)"stream:1"}"); + cmd.AppendFormatted(RespLiterals.MaxLenApprox); + cmd.AppendFormatted((RedisValue)1000); + cmd.AppendFormatted((RedisValue)"*"); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal("*6|$4|XADD|$8|stream:1|$6|MAXLEN|$1|~|$4|1000|$1|*|", Frame(frame)); + Assert.Equal(6, frame.ArgCount); + + // the key is still found, and still routes + Span ranges = stackalloc KeyRange[2]; + Assert.Equal(1, frame.TryGetKeys(ranges)); + Assert.Equal("stream:1", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"stream:1"), frame.Slot); + } + + [Fact] + public void FragmentAfterAKeyStillTracksLaterKeys() + { + // a key, then a two-token fragment, then another key: the second key must still be marked + var ctx = new RespContext(); + var cmd = ctx.Compose(RedisCommand.SMOVE, $"{(RedisKey)"src"}"); + cmd.AppendFormatted(RespLiterals.MaxLenApprox); // not meaningful for SMOVE; exercises the cursor + cmd.AppendFormatted((RedisKey)"dst"); + using var frame = ctx.Execute(ref cmd); + + Assert.Equal(5, frame.ArgCount); + Span ranges = stackalloc KeyRange[2]; + Assert.Equal(2, frame.TryGetKeys(ranges)); + Assert.Equal("src", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + Assert.Equal("dst", Encoding.UTF8.GetString(frame.GetKey(ranges[1]).ToArray())); + } + + [Fact] + public void ConfigGetReadsAsTheCommandDoes() + { + var ctx = new RespContext(); + using var frame = ctx.Execute(RedisCommand.CONFIG, $"{RespLiterals.ConfigGet} {(RedisValue)"maxmemory"}"); + + Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Frame(frame)); + } +} From 6f85660cf8f4c3e1473653af86b25432ab403ecc Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:07:06 +0100 Subject: [PATCH 023/360] Format specifiers: wrong for "raw", right for units Revisits the specifier idea, which was previously recorded as simply dropped. Two different uses with opposite answers. Dropped: {blob:R} to mark a hole as pre-framed. That is an assertion about the argument's nature, which a type expresses better, and RespFragment now does. Kept: {ttl:s} to choose an encoding the type cannot determine. TimeSpan has no single correct RESP encoding - EXPIRE and EX want seconds, PEXPIRE and PX want milliseconds - and the same applies to DateTime for EXPIREAT versus PEXPIREAT, and to bool for 0/1 versus the yes/no that CONFIG SET takes. The finding that answers the objection which sank :R is that the specifier can be made MANDATORY by the compiler: declare only AppendFormatted(TimeSpan, string) and omit the one-argument overload, and $"{ttl}" fails to compile. The dangerous case is an absent unit rather than a mistyped one, and absence is now a build error. Types with a safe default keep their one-argument overload and are unaffected. The catch is that the unit is coupled to the command, which the compiler cannot see: today's code picks both together, useSeconds then HEXPIRE versus HPEXPIRE. So {RedisCommand.PEXPIRE} with {ttl:s} would compile and be wrong. That is an analyzer rule, and a new kind - relating a specifier to another hole's value - tractable since the command is normally a literal, but more involved than the per-hole checks. Also replaces the synthetic SMOVE fragment test with a real LMOVE one (LEFT RIGHT is a genuine fixed pair), keeping the synthetic case but labelling it as deliberately synthetic, since no standard command places a two-token fragment between two keys. --- design/interpolated-resp-writer.md | 45 +++++++++++++++---- .../InterpolatedWriterFragmentTests.cs | 29 ++++++++++-- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 95c6f8c62..7866610f4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -270,12 +270,41 @@ The optional `argCount` defaulting to 1 is fine here, despite the binary-compat parameters — that rule is about *shipped public* API, and if the constructor stays internal with `.Resp()` as the public factory (§2.3), the generated call site is inside the assembly. -**Considered and dropped: a format specifier.** The compiler supports `{value:R}`, binding to -`AppendFormatted(T value, string format)`, so a hole could have been *marked* raw rather than typed raw. -It was tried and works, but loses on three counts: the specifier is only checked at runtime, so `{x:r}` -or `{x:Raw}` compiles and silently falls through to whatever the default branch does; it cannot carry -`ArgCount`; and once raw fragments are a distinct type the marker is redundant anyway. The typed wrapper -gives compile-time dispatch and a place to put the arg count, so `:R` earns nothing. +#### Format specifiers: wrong for "raw", right for units + +Two different uses, with opposite answers. + +**Dropped — `{blob:R}` to mark a hole as pre-framed.** This is an assertion about the argument's +*nature*, which a type expresses better. It was tried and works, but the specifier is only checked at +runtime (`{x:r}` or `{x:Raw}` compiles and falls through silently), it cannot carry `ArgCount`, and once +raw fragments are a distinct type (`RespFragment`) the marker is redundant. + +**Kept — `{ttl:s}` to choose an encoding the type cannot determine.** `TimeSpan` has no single correct +RESP encoding: `EXPIRE`/`EX` want seconds, `PEXPIRE`/`PX` want milliseconds. Likewise `DateTime` for +`EXPIREAT` versus `PEXPIREAT`, and `bool` for `0`/`1` versus the `yes`/`no` that `CONFIG SET` takes +(`RedisLiterals.yes`/`no` already exist). This is what format specifiers are *for*. + +**The specifier can be made mandatory, by the compiler.** Declare only +`AppendFormatted(TimeSpan, string format)` and omit the one-argument overload, and `$"{ttl}"` fails to +compile (`CS1503`). So for a type with no safe default the unit is *required*, enforced by overload +resolution rather than by the analyzer — which answers the objection that sank `:R`: the dangerous case +is not a mistyped specifier but an absent one, and absence is a build error. Types that do have a safe +default (`int`, `RedisValue`) simply keep their one-argument overload and are unaffected. Verified: + +``` +{ttl:s} -> 300 {ttl:ms} -> 300000 {ttl} -> does not compile +{42} -> 42 {true} -> 1 {true:yn} -> yes +``` + +**The catch is that the unit is coupled to the command**, and the compiler cannot see that. Today's code +picks both together — `useSeconds = milliseconds % 1000 == 0`, then `HEXPIRE` versus `HPEXPIRE` +(`RedisDatabase.cs:447-449`). So `$"{RedisCommand.PEXPIRE} {key} {ttl:s}"` would compile and be wrong. +That is an analyzer rule, and a new *kind* of rule: relating a specifier to the value of another hole. +Tractable, because the command is normally a literal at the call site, but more involved than the +per-hole checks in §7. + +A mistyped-but-present specifier still falls through to a runtime `FormatException`, so the analyzer +should also pin the valid set per type. ### 2.4 `RedisCommand` and CommandMap @@ -1013,9 +1042,9 @@ A working spike, all `internal`, so there is no public API commitment yet. | `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 41 tests | | `src/StackExchange.Redis/Interpolated/RespFragment.cs` | pre-framed token runs + the `[Resp]` marker | | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | -| `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 5 tests; both halves of the partial-property pattern, hand-written | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 6 tests; both halves of the partial-property pattern, hand-written | -Green on net10.0 and net8.0 (53 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` +Green on net10.0 and net8.0 (54 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. `InterpolatedWriterDemo` is the readable end-to-end example — each case asserts the exact rendered frame, diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index 1603f537e..950906d0d 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -33,6 +33,10 @@ internal static partial class RespLiterals /// MAXLEN ~, the two arguments preceding an XADD/XTRIM threshold. [Resp("maxlen", "~")] internal static partial RespFragment MaxLenApprox { get; } + + /// LEFT RIGHT, the fixed pair ending an LMOVE. + [Resp("left", "right")] + internal static partial RespFragment LeftRight { get; } } // ---- half 2: what the GENERATOR would emit ---------------------------------------------------- @@ -47,6 +51,8 @@ internal static partial class RespLiterals internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nLIB-NAME\r\n"u8, 2); internal static partial RespFragment MaxLenApprox => new("$6\r\nMAXLEN\r\n$1\r\n~\r\n"u8, 2); + + internal static partial RespFragment LeftRight => new("$4\r\nLEFT\r\n$5\r\nRIGHT\r\n"u8, 2); } private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); @@ -96,12 +102,29 @@ public void MultiTokenFragmentDoesNotShiftKeyMarks() } [Fact] - public void FragmentAfterAKeyStillTracksLaterKeys() + public void TwoKeysThenATwoTokenFragment() { - // a key, then a two-token fragment, then another key: the second key must still be marked + // LMOVE source destination LEFT RIGHT - a real command ending in a fixed two-token pair + var ctx = new RespContext(); + using var frame = ctx.Execute(RedisCommand.LMOVE, $"{(RedisKey)"src"} {(RedisKey)"dst"} {RespLiterals.LeftRight}"); + + Assert.Equal("*5|$5|LMOVE|$3|src|$3|dst|$4|LEFT|$5|RIGHT|", Frame(frame)); + Assert.Equal(5, frame.ArgCount); + + Span ranges = stackalloc KeyRange[2]; + Assert.Equal(2, frame.TryGetKeys(ranges)); + Assert.Equal("src", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + Assert.Equal("dst", Encoding.UTF8.GetString(frame.GetKey(ranges[1]).ToArray())); + } + + [Fact] + public void KeyAfterAMultiTokenFragmentIsStillTracked() + { + // Synthetic: no standard command puts a two-token fragment BETWEEN two keys. The cursor arithmetic + // has to hold regardless, since ArgCount is what keeps later key-mark positions correct. var ctx = new RespContext(); var cmd = ctx.Compose(RedisCommand.SMOVE, $"{(RedisKey)"src"}"); - cmd.AppendFormatted(RespLiterals.MaxLenApprox); // not meaningful for SMOVE; exercises the cursor + cmd.AppendFormatted(RespLiterals.MaxLenApprox); cmd.AppendFormatted((RedisKey)"dst"); using var frame = ctx.Execute(ref cmd); From a5057f8777fe6c47b5fb1c19a1222cc183d26991 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:18:16 +0100 Subject: [PATCH 024/360] Token casing: upper by default, verbatim when the attribute gives it Counting what RedisLiterals actually sends: 138 upper-case, 31 lower-case, zero mixed, out of 165. So an inferred token - no attribute, taken from the member name - should be upper-cased, which is right about 84% of the time and matches what CommandMap already does to command names. The lower-case minority is not arbitrary, which is why one rule is not enough: those tokens are values rather than keywords - yes/no, lib-name/lib-ver, replica/slave/sentinel/pubsub, config parameter names such as databases and timeout, the geo units km/mi/ft/m, and the markers #, -, + and *. So a token given in the attribute is used verbatim: one rule, no extra flag, and it covers the case that forces the issue - a single fragment containing both. This corrected the worked example, which had been emitting LIB-NAME where the library sends lib-name; CLIENT SETINFO lib-name is the current wire format and upper-casing it would have been a gratuitous change. A generator that upper-cased everything unconditionally would have made that mistake silently. --- design/interpolated-resp-writer.md | 23 +++++++++++++++++++ .../InterpolatedWriterFragmentTests.cs | 21 ++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 7866610f4..9b41f77a1 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -259,6 +259,29 @@ from `AsciiHashAttribute` transfer directly: the token is **inferred from the me attribute overrides it, and the attribute is `[Conditional("DEBUG")]` so it evaporates from shipped metadata while the generator still sees it in source. +##### Casing + +**Default to upper-case; the attribute gives verbatim control.** Counting the tokens `RedisLiterals` +actually sends: **138 upper-case, 31 lower-case, 0 mixed**, out of 165. So an inferred token — one with +no attribute, taken from the member name — should be upper-cased, which is right ~84% of the time and +matches what `CommandMap` already does to command names for the canonicality reason in §6.3. + +The lower-case minority is not arbitrary, which is why a single rule is not enough: those tokens are +**values rather than keywords**. `yes`/`no`, `lib-name`/`lib-ver`, `replica`/`slave`/`sentinel`/`pubsub`, +config parameter names such as `databases`/`timeout`, the geo units `km`/`mi`/`ft`/`m`, and the markers +`#`/`-`/`+`/`*`. + +So: **a token given in the attribute is used verbatim.** One rule, no extra flag, and it handles the case +that forces the issue — a single fragment containing both: + +```csharp +[Resp("SETINFO", "lib-name")] // keyword upper, attribute name lower - as sent today +``` + +`CLIENT SETINFO lib-name` is the shape the library sends now; emitting `LIB-NAME` would be a gratuitous +change to the wire format. Worth noting only because a generator that upper-cased everything +unconditionally would make exactly that mistake silently. + **It largely removes the need for the raw-fragment analyzer rules (§7.1-3).** Those exist to validate hand-written `u8`: framing, matching length prefixes, uppercase tokens. A generator emits all three correctly *by construction* — there is nothing left to check. The rule becomes "don't hand-write these" diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index 950906d0d..e2692d94e 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -23,24 +23,29 @@ internal static partial class RespLiterals internal static partial RespFragment EX { get; } /// The subcommand of CONFIG GET - one argument, name would not suffice. - [Resp("get")] + [Resp("GET")] internal static partial RespFragment ConfigGet { get; } - /// SETINFO LIB-NAME, the two arguments following CLIENT. - [Resp("setinfo", "lib-name")] + /// + /// SETINFO lib-name, the two arguments following CLIENT. Note the mixed casing: the + /// subcommand is a keyword and upper-cased, the attribute name is a value and is not - which is + /// what RedisLiterals sends today, and why tokens given in the attribute are taken verbatim. + /// + [Resp("SETINFO", "lib-name")] internal static partial RespFragment SetInfoLibName { get; } /// MAXLEN ~, the two arguments preceding an XADD/XTRIM threshold. - [Resp("maxlen", "~")] + [Resp("MAXLEN", "~")] internal static partial RespFragment MaxLenApprox { get; } /// LEFT RIGHT, the fixed pair ending an LMOVE. - [Resp("left", "right")] + [Resp("LEFT", "RIGHT")] internal static partial RespFragment LeftRight { get; } } // ---- half 2: what the GENERATOR would emit ---------------------------------------------------- - // Tokens upper-cased for cache-key canonicality, exactly as CommandMap already does for commands. + // A token inferred from the member name is upper-cased; a token given in the attribute is verbatim, + // because the library sends both cases and the distinction is semantic - see the design notes. internal static partial class RespLiterals { @@ -48,7 +53,7 @@ internal static partial class RespLiterals internal static partial RespFragment ConfigGet => new("$3\r\nGET\r\n"u8); - internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nLIB-NAME\r\n"u8, 2); + internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nlib-name\r\n"u8, 2); internal static partial RespFragment MaxLenApprox => new("$6\r\nMAXLEN\r\n$1\r\n~\r\n"u8, 2); @@ -74,7 +79,7 @@ public void TwoTokenFragmentCountsAsTwoArguments() var ctx = new RespContext(); using var frame = ctx.Execute(RedisCommand.CLIENT, $"{RespLiterals.SetInfoLibName} {(RedisValue)"StackExchange.Redis"}"); - Assert.Equal("*4|$6|CLIENT|$7|SETINFO|$8|LIB-NAME|$19|StackExchange.Redis|", Frame(frame)); + Assert.Equal("*4|$6|CLIENT|$7|SETINFO|$8|lib-name|$19|StackExchange.Redis|", Frame(frame)); // the fragment is TWO arguments: *4, not *3 - this is what ArgCount exists for Assert.Equal(4, frame.ArgCount); From c7fcadc4e319fdc0ea4595066081ad84156dd317 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:24:44 +0100 Subject: [PATCH 025/360] Make the writer public, gated behind SER010 and SER011 Two experiment IDs rather than one. SER010 gates the feature and is in the repo-wide NoWarn so internal use is quiet. SER011 gates hand-construction of a RespFragment and is deliberately NOT in NoWarn, because the whole point is that it should be seen: nothing validates the bytes, and a length prefix that disagrees with its payload, a missing CRLF, or an ArgCount that does not match the $ runs will corrupt the connection for every command that follows, with the first symptom appearing somewhere unrelated. Generated code suppresses it at the emit site and nowhere wider, which the fragment tests now demonstrate; verified that it fires unsuppressed, as an error carrying the docs link. The blocker was that RedisCommand is internal, and deliberately so - the public surface exposes commands as typed methods or Execute(string command, ...), and making the enum public would be a large permanent commitment to an implementation detail. Resolved with a string overload that speculatively parses via RedisCommandMetadata.TryParseCI, which is exactly what Execute(string) does today: a recognised name still gets command-map aliasing and disabling, and anything unrecognised is framed verbatim. RedisCommand stays internal, and the overloads taking it stay internal alongside it. Four new tests cover the string path - aliasing, case-insensitivity, disabled commands, and an unrecognised name going out as written. 58 total, green on net10.0 and net8.0, net481 compiles, Release analyzers clean. Note ExperimentalAttribute.Message is .NET 9+, so a custom message is not usable while this targets net461 through net10.0; docs/exp/SER011.md carries the explanation and UrlFormat links to it, which is the existing convention. --- Directory.Build.props | 2 +- design/interpolated-resp-writer.md | 50 ++++++++++++++- docs/exp/SER010.md | 13 ++++ docs/exp/SER011.md | 23 +++++++ src/RESPite/Shared/Experiments.cs | 9 +++ .../Interpolated/RespCommandHandler.cs | 63 ++++++++++++++++++- .../Interpolated/RespContext.cs | 56 +++++++++++++++-- .../Interpolated/RespFragment.cs | 37 ++++++++++- .../Interpolated/RespFrame.cs | 14 ++++- .../PublicAPI/PublicAPI.Unshipped.txt | 53 ++++++++++++++++ .../InterpolatedWriterFragmentTests.cs | 50 +++++++++++++++ 11 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 docs/exp/SER010.md create mode 100644 docs/exp/SER011.md diff --git a/Directory.Build.props b/Directory.Build.props index f5981dc8b..c0863db83 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001;SER004;SER005;SER007;SER008;SER009 + $(NoWarn);NU5105;NU1507;SER001;SER004;SER005;SER007;SER008;SER009;SER010 https://github.com/StackExchange/StackExchange.Redis/releases https://seredis.dev/ MIT diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 9b41f77a1..6a2482c8b 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1054,7 +1054,7 @@ keyspace notifications, and script/`Execute` results. ## 9. The spike in this repo -A working spike, all `internal`, so there is no public API commitment yet. +A working spike. The surface is public but gated behind `SER010`/`SER011` — see §9.1. | File | What it is | | --- | --- | @@ -1067,7 +1067,7 @@ A working spike, all `internal`, so there is no public API commitment yet. | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | | `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 6 tests; both halves of the partial-property pattern, hand-written | -Green on net10.0 and net8.0 (54 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` +Green on net10.0 and net8.0 (58 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. `InterpolatedWriterDemo` is the readable end-to-end example — each case asserts the exact rendered frame, @@ -1114,6 +1114,52 @@ that the context can be threaded through to result processing is design, not dem --- +### 9.1 Public API, and the `RedisCommand` problem + +The surface is public but **experimental**, under two diagnostic IDs: + +| | | +| --- | --- | +| `SER010` | the feature as a whole; in the repo-wide `NoWarn`, so internal use is quiet | +| `SER011` | **hand-constructing a `RespFragment`**; deliberately NOT in `NoWarn` | + +`SER011` exists because nothing validates the bytes handed to `new RespFragment(...)`: a length prefix +that disagrees with its payload, a missing CRLF, or an `ArgCount` that does not match the `$` runs will +corrupt the connection for every command that follows, with the first symptom appearing somewhere +unrelated. So the ctor is gated, and *generated* code suppresses it at the emit site and nowhere wider: + +```csharp +#pragma warning disable SER011 // this half stands in for the generator +internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); +#pragma warning restore SER011 +``` + +Verified that it fires unsuppressed, as an error carrying the docs link +(`https://seredis.dev/exp/SER011`). + +One portability note: `ExperimentalAttribute.Message` is .NET 9+, so a custom message cannot be used +while this targets net461 through net10.0. The explanation lives in `docs/exp/SER011.md`, which +`UrlFormat` links to — which is the existing convention here anyway. + +**The blocker was `RedisCommand`, which is `internal`.** The whole design routes commands through it for +`CommandMap` aliasing, but the public surface deliberately exposes commands as typed methods or +`Execute(string command, ...)`; making that enum public would be a large, permanent commitment to an +implementation detail. + +Resolved by giving public callers a **string overload that speculatively parses**, which is exactly what +`Execute(string)` already does (`RedisDatabase.cs:6149-6155`): + +```csharp +if (!RedisCommandMetadata.TryParseCI(adhocCommand, out knownCommand)) + knownCommand = RedisCommand.UNKNOWN; +``` + +So a recognised name still gets command-map aliasing and disabling; anything unrecognised is framed +verbatim. `RedisCommand` stays internal, and those overloads stay internal alongside it. Behaviour is +consistent with the existing ad-hoc command path rather than a second set of rules. + +--- + ## 10. Open questions - **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which diff --git a/docs/exp/SER010.md b/docs/exp/SER010.md new file mode 100644 index 000000000..67a4dc4c8 --- /dev/null +++ b/docs/exp/SER010.md @@ -0,0 +1,13 @@ +SER010 +- + +The interpolated-string RESP writer: `RespContext`, `RespCommandHandler`, `RespFrame`, `RespFragment` +and friends. + +This is an experiment. The shape of the API, and whether it ships at all, is not settled; expect it to +change. It is exposed publicly so that it can be *used* and argued with, not because it is stable. + +Design notes, including what has been measured and what has only been reasoned about, are in +`design/interpolated-resp-writer.md`. + +To opt in, add `SER010` to `NoWarn`, or suppress it at the call site. diff --git a/docs/exp/SER011.md b/docs/exp/SER011.md new file mode 100644 index 000000000..71c2b41f1 --- /dev/null +++ b/docs/exp/SER011.md @@ -0,0 +1,23 @@ +SER011 +- + +You are constructing a `RespFragment` by hand, from raw bytes. + +**Don't.** A `RespFragment` is a run of already-framed RESP bulk strings, and nothing checks that what +you pass is well-formed. Get it wrong - a length prefix that disagrees with its payload, a missing CRLF, +an `ArgCount` that does not match the number of `$` runs - and you will corrupt the connection for every +command that follows, not just your own. There is no validation, and there is no diagnostic; the first +symptom is a protocol desync somewhere unrelated. + +Declare the fragment instead, and let the generator emit it: + +```csharp +[Resp] private static partial RespFragment EX { get; } // token inferred: "EX" +[Resp("SETINFO", "lib-name")] private static partial RespFragment SetInfoLibName { get; } +``` + +The generator produces the framing, the length prefixes and the argument count from the tokens, so all +three are correct by construction. That is the only supported way to make one. + +If you are the generator - or you genuinely know what you are doing and accept that this is undefined +behaviour - suppress `SER011` at the point of construction, and nowhere wider. diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index 2cfcd94d6..0501ad235 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -22,6 +22,15 @@ internal static class Experiments public const string Server_8_10 = "SER008"; public const string Transport = "SER009"; + /// The interpolated-string RESP writer: RespContext, the command handler, and friends. + public const string InterpolatedWriter = "SER010"; + + /// + /// Constructing a RespFragment by hand. Deliberately NOT in the global NoWarn: the whole point + /// is that it should be seen. Generated code suppresses it at the emit site. + /// + public const string HandWrittenRespFragment = "SER011"; + // ReSharper restore InconsistentNaming // this one is not a real experiment; it exists to help me diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 02922f170..c148422da 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -1,7 +1,10 @@ using System; using System.Buffers; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Text; +using RESPite; namespace StackExchange.Redis.Interpolated { @@ -14,7 +17,8 @@ namespace StackExchange.Redis.Interpolated /// compiler-supplied formattedCount the argument count. See design/interpolated-resp-writer.md. /// [InterpolatedStringHandler] - internal ref struct RespCommandHandler + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public ref struct RespCommandHandler { /// '*' plus up to nine digits plus CRLF; reserved at the front so the header can be /// back-filled right-aligned once the final argument count is known. @@ -29,6 +33,10 @@ internal ref struct RespCommandHandler private ulong _keyMarks; private bool _hasCommand; + /// Initialize with the command supplied as the first hole. + /// Total length of the literal segments; compiler-supplied. + /// Number of holes; compiler-supplied. + /// The receiver of the call, supplying the command map and prefixes. public RespCommandHandler(int literalLength, int formattedCount, RespContext context) { _context = context; @@ -51,7 +59,7 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con /// repeat, being configuration-driven - drops nothing on the floor. See /// design/interpolated-resp-writer.md section 6.5. /// - public RespCommandHandler(int literalLength, int formattedCount, RespContext context, RedisCommand command) + internal RespCommandHandler(int literalLength, int formattedCount, RespContext context, RedisCommand command) { // resolve FIRST: this throws before anything is rented var resp = context.CommandMap.GetResp(command); @@ -70,6 +78,48 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _argIndex = 1; } + /// + /// Initialize from a command name, for callers without access to the internal + /// RedisCommand enum. The name is speculatively parsed to a known command so command-map + /// aliasing and disabling still apply; anything unrecognised is framed verbatim, matching how + /// IDatabase.Execute(string, ...) already behaves. + /// + public RespCommandHandler(int literalLength, int formattedCount, RespContext context, string command) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + + ReadOnlySpan resp = default; + var known = RedisCommandMetadata.TryParseCI(command.AsSpan(), out var parsed) && parsed != RedisCommand.UNKNOWN; + if (known) + { + resp = context.CommandMap.GetResp(parsed); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(parsed); + } + + var nameBytes = known ? 0 : Encoding.UTF8.GetByteCount(command); + _context = context; + _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + resp.Length + nameBytes + literalLength + (formattedCount * 24)); + _offset = HeaderMax; + _slot = ServerSelectionStrategy.NoSlot; + _keyMarks = 0; + _hasCommand = true; + _args = 1; + _argIndex = 1; + + if (known) + { + resp.CopyTo(_buffer.AsSpan(_offset)); + _offset += resp.Length; + } + else + { + var payloadOffset = 0; + WriteBulk(nameBytes, out payloadOffset); + Encoding.UTF8.GetBytes(command, 0, command.Length, _buffer, _offset + payloadOffset); + CommitBulk(payloadOffset, nameBytes); + } + } + /// /// Literal text is rejected, with one exception: a single space, which is discarded. That keeps /// $"{RedisCommand.SET} {key} {value}" readable - it mirrors how the command is written @@ -95,7 +145,7 @@ public void AppendLiteral(string value) + "Write $\"{RedisCommand.SET} {key} {value}\", not $\"SET {key} {value}\".", nameof(value)); - public void AppendFormatted(RedisCommand value) + internal void AppendFormatted(RedisCommand value) { if (_hasCommand) throw new InvalidOperationException("The command must be the first argument, and may only be given once."); @@ -110,6 +160,8 @@ public void AppendFormatted(RedisCommand value) _argIndex++; } + /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. + /// The key to append. public void AppendFormatted(RedisKey value) { DemandCommand(); @@ -133,6 +185,8 @@ public void AppendFormatted(RedisKey value) _argIndex++; } + /// Append a channel, applying the channel prefix unless the channel opts out. + /// The channel to append. public void AppendFormatted(RedisChannel value) { DemandCommand(); @@ -169,6 +223,8 @@ public void AppendFormatted(RespFragment value) _argIndex += value.ArgCount; } + /// Append a value; not a key, and not marked as one. + /// The value to append. public void AppendFormatted(RedisValue value) { DemandCommand(); @@ -201,6 +257,7 @@ public RespFrame Complete() return frame; } + /// Return the buffer to the pool, if it has not already been handed to a frame. public void Dispose() { var buffer = _buffer; diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index a4c1c682f..3c4acbeed 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -1,6 +1,8 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using RESPite; namespace StackExchange.Redis.Interpolated { @@ -24,9 +26,10 @@ namespace StackExchange.Redis.Interpolated /// (via ) rather than conflict. /// /// - internal readonly struct RespContext + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespContext { - public RespContext( + internal RespContext( CommandMap? commandMap = null, RedisKey keyPrefix = default, RedisChannel channelPrefix = default, @@ -59,20 +62,30 @@ public RespContext( /// The key prefix as raw bytes, so the writer never pays a conversion per command. internal ReadOnlySpan KeyPrefixSpan => _keyPrefix; + /// The prefix applied to channels written through this context. public RedisChannel ChannelPrefix { get; } + /// The database index; part of cache identity, and NOT part of the rendered frame. public int Database { get; } + /// The server type; cluster slots are only computed when this is a cluster. public ServerType ServerType { get; } + /// Cancellation for operations issued through this context. public CancellationToken CancellationToken { get; } + /// A copy of this context with a different cancellation token. + /// The token to use. public RespContext WithCancellationToken(CancellationToken cancellationToken) => new(CommandMap, KeyPrefix, ChannelPrefix, Database, ServerType, cancellationToken); + /// A copy of this context targeting a different database. + /// The database index. public RespContext WithDatabase(int database) => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken); + /// A copy of this context with a different server type. + /// The server type. public RespContext WithServerType(ServerType serverType) => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken); @@ -90,6 +103,8 @@ public RespContext WithKeyPrefix(RedisKey keyPrefix) ServerType, CancellationToken); + /// A copy of this context with a different channel prefix. + /// The prefix to apply to channels. public RespContext WithChannelPrefix(RedisChannel channelPrefix) => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken); @@ -135,7 +150,7 @@ public RespCommandHandler Compose([InterpolatedStringHandlerArgument("")] ref Re /// ("", nameof(command)) passes the receiver and the command into the handler's /// constructor, which lets the command map be consulted before the buffer is rented. /// - public RespCommandHandler Compose( + internal RespCommandHandler Compose( RedisCommand command, [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) => handler; @@ -150,17 +165,48 @@ public RespCommandHandler Compose( /// /// The command to issue. /// Expected number of arguments, used only to size the initial rent. - public RespCommandHandler Compose(RedisCommand command, int argHint = 0) + internal RespCommandHandler Compose(RedisCommand command, int argHint = 0) => new(0, argHint < 0 ? 0 : argHint, this, command); /// - /// As , with the command as a real argument. + /// As the RedisCommand overload, taking a command name. The name is speculatively + /// parsed to a known command so aliasing and disabling still apply; anything unrecognised is framed + /// verbatim, as IDatabase.Execute(string, ...) does. /// + /// The command name to issue. + /// The interpolated arguments. + public RespCommandHandler Compose( + string command, + [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) + => handler; + + /// As the RedisCommand overload, taking a command name. + /// The command name to issue. + /// The interpolated arguments. public RespFrame Execute( + string command, + [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) + => Execute(ref handler); + + /// + /// As , with the command as a real argument. + /// + internal RespFrame Execute( RedisCommand command, [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) => Execute(ref handler); + /// + /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into + /// the handler's constructor; that is how the handler reaches the command map, the prefixes, and the + /// server type. + /// + /// + /// A real Execute would go on to dispatch the frame; this spike stops at "the right bytes were + /// rendered, and we know which arguments were keys". + /// + /// The interpolated command and arguments. + /// The rendered frame, with routing and key metadata. public RespFrame Execute([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) { if (CancellationToken.IsCancellationRequested) diff --git a/src/StackExchange.Redis/Interpolated/RespFragment.cs b/src/StackExchange.Redis/Interpolated/RespFragment.cs index 481727429..ec63ec720 100644 --- a/src/StackExchange.Redis/Interpolated/RespFragment.cs +++ b/src/StackExchange.Redis/Interpolated/RespFragment.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis.Interpolated { @@ -18,8 +20,22 @@ namespace StackExchange.Redis.Interpolated /// design/interpolated-resp-writer.md section 2.3. /// /// - internal readonly ref struct RespFragment + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly ref struct RespFragment { + /// + /// Construct a fragment from raw bytes. Nothing validates them. + /// + /// + /// Deliberately gated: framing, length prefixes and must all be correct, + /// and a mistake corrupts the connection for every command that follows, with the first symptom + /// appearing somewhere unrelated. Declare a [Resp] partial property instead and let the + /// generator emit all three correctly. Generated code suppresses this at the emit site, and nowhere + /// wider. + /// + // NOTE: no Message = here - ExperimentalAttribute.Message is .NET 9+, and this has to compile on + // net461/netstandard2.0/net472/net8.0 too. SER011.md carries the explanation; UrlFormat links to it. + [Experimental(Experiments.HandWrittenRespFragment, UrlFormat = Experiments.UrlFormat)] public RespFragment(ReadOnlySpan bytes, int argCount = 1) { if (argCount < 1) throw new ArgumentOutOfRangeException(nameof(argCount)); @@ -39,10 +55,25 @@ public RespFragment(ReadOnlySpan bytes, int argCount = 1) /// Omit the tokens to infer a single token from the member name, as AsciiHashAttribute does. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - internal sealed class RespAttribute : Attribute + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class RespAttribute : Attribute { - public RespAttribute(params string[] tokens) => Tokens = tokens; + /// Infer a single token from the member name, upper-cased. + public RespAttribute() => Tokens = Array.Empty(); + /// Use these tokens verbatim; casing is not adjusted. + public RespAttribute(string token) => Tokens = new[] { token }; + + /// Use these tokens verbatim, as a multi-argument fragment; casing is not adjusted. + public RespAttribute(string token, params string[] additionalTokens) + { + var tokens = new string[additionalTokens.Length + 1]; + tokens[0] = token; + additionalTokens.CopyTo(tokens, 1); + Tokens = tokens; + } + + /// The tokens to emit; empty means "infer from the member name". public string[] Tokens { get; } } } diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 16fb7cf3e..8c14cfed9 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -1,5 +1,7 @@ using System; using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis.Interpolated { @@ -7,7 +9,8 @@ namespace StackExchange.Redis.Interpolated /// EXPERIMENTAL SPIKE. A rendered RESP frame, plus the routing and invalidation metadata that was /// folded while it was being written. /// - internal struct RespFrame : IDisposable + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public struct RespFrame : IDisposable { // key marks: MSB clear => up to two 31-bit BUFFER-ABSOLUTE byte offsets, resolvable with no scan; // MSB set => the frame must be walked to recover keys. Zero means "no keys" - offset 0 can never be @@ -81,6 +84,7 @@ private readonly KeyRange PayloadOf(int offset) return new KeyRange(i + 2, length); } + /// Return the underlying buffer to the pool; safe to call more than once. public void Dispose() { var buffer = _buffer; @@ -97,16 +101,22 @@ public void Dispose() /// must be internal (a public one would collide with the real type on newer targets), so it cannot /// appear in API that a consumer might one day see. /// - internal readonly struct KeyRange + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct KeyRange { + /// Create a range over a payload within a frame's buffer. + /// Buffer-absolute offset of the payload. + /// Payload length in bytes. public KeyRange(int offset, int length) { Offset = offset; Length = length; } + /// Buffer-absolute offset of the payload. public int Offset { get; } + /// Payload length, in bytes. public int Length { get; } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..c36df3290 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,54 @@ #nullable enable +[SER010]StackExchange.Redis.Interpolated.KeyRange +[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void +[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void +[SER010]StackExchange.Redis.Interpolated.KeyRange.Length.get -> int +[SER010]StackExchange.Redis.Interpolated.KeyRange.Offset.get -> int +[SER010]StackExchange.Redis.Interpolated.RespAttribute +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token, params string![]! additionalTokens) -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.Tokens.get -> string![]! +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Complete() -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void +[SER010]StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.CancellationToken.get -> System.Threading.CancellationToken +[SER010]StackExchange.Redis.Interpolated.RespContext.ChannelPrefix.get -> StackExchange.Redis.RedisChannel +[SER010]StackExchange.Redis.Interpolated.RespContext.CommandMap.get -> StackExchange.Redis.CommandMap! +[SER010]StackExchange.Redis.Interpolated.RespContext.Compose(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler +[SER010]StackExchange.Redis.Interpolated.RespContext.Compose(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler +[SER010]StackExchange.Redis.Interpolated.RespContext.Database.get -> int +[SER010]StackExchange.Redis.Interpolated.RespContext.Execute(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespContext.Execute(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey +[SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void +[SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType +[SER010]StackExchange.Redis.Interpolated.RespContext.WithCancellationToken(System.Threading.CancellationToken cancellationToken) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithChannelPrefix(StackExchange.Redis.RedisChannel channelPrefix) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespFragment +[SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespFragment.Bytes.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespFragment.RespFragment() -> void +[SER010]StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespFrame.ArgCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespFrame.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespFrame.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespFrame.HasNoKeys.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespFrame.KeysNeedScan.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespFrame.RespFrame() -> void +[SER010]StackExchange.Redis.Interpolated.RespFrame.Slot.get -> int +[SER010]StackExchange.Redis.Interpolated.RespFrame.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespFrame.TryGetKeys(scoped System.Span target) -> int +[SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index e2692d94e..ee404b725 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text; using StackExchange.Redis.Interpolated; using Xunit; @@ -44,9 +45,13 @@ internal static partial class RespLiterals } // ---- half 2: what the GENERATOR would emit ---------------------------------------------------- + // + // Constructing a RespFragment by hand is gated behind SER011 precisely because nothing validates the + // bytes. Generated code suppresses it AT THE EMIT SITE and nowhere wider, which is what this shows. // A token inferred from the member name is upper-cased; a token given in the attribute is verbatim, // because the library sends both cases and the distinction is semantic - see the design notes. +#pragma warning disable SER011 // hand-constructed RespFragment: this half stands in for the generator internal static partial class RespLiterals { internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); @@ -59,6 +64,7 @@ internal static partial class RespLiterals internal static partial RespFragment LeftRight => new("$4\r\nLEFT\r\n$5\r\nRIGHT\r\n"u8, 2); } +#pragma warning restore SER011 private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); @@ -148,4 +154,48 @@ public void ConfigGetReadsAsTheCommandDoes() Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Frame(frame)); } + + // ---- the public, string-based command overload ------------------------------------------------- + + [Fact] + public void StringCommandIsSpeculativelyParsedAndAliased() + { + // a recognised name goes through the command map, so renames still apply + var map = CommandMap.Create(new Dictionary { ["get"] = "xget" }); + var ctx = new RespContext(map); + using var frame = ctx.Execute("get", $"{(RedisKey)"k"}"); + + Assert.Equal("*2|$4|XGET|$1|k|", Frame(frame)); + } + + [Fact] + public void StringCommandIsCaseInsensitive() + { + var ctx = new RespContext(); + using var upper = ctx.Execute("GET", $"{(RedisKey)"k"}"); + using var lower = ctx.Execute("get", $"{(RedisKey)"k"}"); + + Assert.Equal("*2|$3|GET|$1|k|", Frame(upper)); + Assert.True(upper.Span.SequenceEqual(lower.Span)); + } + + [Fact] + public void StringCommandRespectsDisabledCommands() + { + var map = CommandMap.Create(new Dictionary { ["get"] = null }); + var ctx = new RespContext(map); + + Assert.Throws(() => ctx.Execute("get", $"{(RedisKey)"k"}").Dispose()); + } + + [Fact] + public void UnrecognisedStringCommandIsFramedVerbatim() + { + // not a known command: no aliasing to apply, so the name goes out as written - matching + // IDatabase.Execute(string, ...) behaviour for ad-hoc commands + var ctx = new RespContext(); + using var frame = ctx.Execute("FT.SEARCH", $"{(RedisValue)"idx"}{(RedisValue)"*"}"); + + Assert.Equal("*3|$9|FT.SEARCH|$3|idx|$1|*|", Frame(frame)); + } } From 9c241accc9e703354da3798fcae593c0569929f9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:25:30 +0100 Subject: [PATCH 026/360] Record why SER011 stays suppressible An analyzer rule tagged NotConfigurable cannot be suppressed by pragma or editorconfig, so the gate on hand-constructing a RespFragment could in principle be absolute. (#error is not the mechanism: a generator emits into its own file, so the diagnostic would point at generated source rather than the call site.) Rejected for three reasons. It would not actually be absolute - the generator emits into the consumer's assembly, so what it calls must be public, so the generator's own output trips the rule; that needs a generated-code exemption, which is spoofable with an auto-generated header, degrading "impossible" into "suppressible by whoever reads the analyzer source" and losing the property that the bypass is greppable. It fails this repo's own bar, since Diagnostics.cs reserves Error for code that cannot work and warns that an error which can be wrong is a broken build on correct code. And it forecloses the legitimate case of a fragment assembled once at startup from configuration. Keeping SER011 out of NoWarn is the proportionate friction: blanket-disabling means adding it to the csproj where review sees it, and per-site suppression means a pragma naming SER011, so git grep SER011 is an exact inventory of every hand-rolled fragment. Auditable beats absent for a failure mode whose whole problem is invisibility. --- design/interpolated-resp-writer.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 6a2482c8b..822e1758b 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1137,6 +1137,30 @@ internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); Verified that it fires unsuppressed, as an error carrying the docs link (`https://seredis.dev/exp/SER011`). +**Considered and rejected: making it unsuppressible.** An analyzer rule tagged +`WellKnownDiagnosticTags.NotConfigurable` cannot be suppressed by pragma or editorconfig, so the gate +could in principle be absolute. (`#error` is not the mechanism — a generator emits into its own file, so +the diagnostic would point at generated source rather than the offending call site.) Three reasons not +to: + +- **It would not actually be absolute.** The generator emits into the *consumer's* assembly, so what it + calls must be public, so the generator's own output trips the rule. That needs a generated-code + exemption, which is spoofable with an `` header — degrading "impossible" into + "suppressible by whoever reads the analyzer source", and losing the property that the bypass is + greppable. +- **It fails this repo's own bar for an error.** `Diagnostics.cs` reserves `DiagnosticSeverity.Error` for + code that *cannot work* — "there is no arrangement of the surrounding code that makes it work" — and + warns that "an error that can be wrong is a broken build on correct code, which is a far worse trade + than a warning that can be wrong". A hand-built fragment with correct bytes works. +- **It forecloses the legitimate case** in §2.3: a fragment assembled once at startup from configuration. + +Keeping `SER011` out of `NoWarn` is the right amount of friction instead. Blanket-disabling it means +adding it to the csproj, where review sees it; per-site suppression means a pragma naming `SER011`, which +makes `git grep SER011` an exact inventory of every hand-rolled fragment in a codebase. Auditable beats +absent, for a failure mode whose whole problem is invisibility. If more teeth are wanted, the +proportionate lever is a rule that flags a *project-wide* suppression while leaving per-site pragmas +alone. + One portability note: `ExperimentalAttribute.Message` is .NET 9+, so a custom message cannot be used while this targets net461 through net10.0. The explanation lives in `docs/exp/SER011.md`, which `UrlFormat` links to — which is the existing convention here anyway. From a56d0631a71ecfef3bc952d9bb3a02f020e90752 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:26:09 +0100 Subject: [PATCH 027/360] Correct the record: #error from a generator IS unsuppressible The previous note claimed #error was not a viable mechanism. It is, and more absolutely than NotConfigurable: verified that #error survives both a blanket #pragma warning disable and a targeted #pragma warning disable CS1029. Both technical objections were wrong. "It points at generated source rather than the call site" - the message carries whatever text the generator puts in it, including the offending file and position. "The generated-code exemption is spoofable" - it is not, because the generator knows precisely which constructions are its own, having emitted them; that is identity rather than an auto-generated header check. What survives is judgement rather than mechanism: it fails this repo's bar of reserving Error for code that cannot work, it forecloses the legitimate case of a fragment assembled once at startup from configuration - which is decisive, since an absolute block needs the sanctioned alternative to exist first - and #error gives no squiggle and no code fix at the offending call site, which is a poor trade for a rule people meet while learning the API. --- design/interpolated-resp-writer.md | 43 ++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 822e1758b..3527dbb95 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1137,24 +1137,39 @@ internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); Verified that it fires unsuppressed, as an error carrying the docs link (`https://seredis.dev/exp/SER011`). -**Considered and rejected: making it unsuppressible.** An analyzer rule tagged -`WellKnownDiagnosticTags.NotConfigurable` cannot be suppressed by pragma or editorconfig, so the gate -could in principle be absolute. (`#error` is not the mechanism — a generator emits into its own file, so -the diagnostic would point at generated source rather than the offending call site.) Three reasons not -to: - -- **It would not actually be absolute.** The generator emits into the *consumer's* assembly, so what it - calls must be public, so the generator's own output trips the rule. That needs a generated-code - exemption, which is spoofable with an `` header — degrading "impossible" into - "suppressible by whoever reads the analyzer source", and losing the property that the bypass is - greppable. +**Making it unsuppressible is possible; the question is whether it should be.** Two mechanisms: an +analyzer rule tagged `WellKnownDiagnosticTags.NotConfigurable`, or a generator that detects a +hand-written construction and emits `#error`. The second is the stronger, and is genuinely absolute — +verified that `#error` survives both a blanket `#pragma warning disable` and a targeted +`#pragma warning disable CS1029`: + +``` +error CS1029: #error: 'RespFragment constructed by hand at Foo.cs(12,34); see https://seredis.dev/exp/SER011' +``` + +Two objections that look like blockers are not: + +- *"It points at generated source rather than the call site."* The **message** carries whatever text the + generator puts in it, including the offending file and position — as above. +- *"The generated-code exemption is spoofable."* It is not: the generator knows precisely which + constructions are its own, because it emitted them. That is identity, not an `` header + check. + +So the reasons not to are about judgement rather than mechanism: + - **It fails this repo's own bar for an error.** `Diagnostics.cs` reserves `DiagnosticSeverity.Error` for code that *cannot work* — "there is no arrangement of the surrounding code that makes it work" — and warns that "an error that can be wrong is a broken build on correct code, which is a far worse trade than a warning that can be wrong". A hand-built fragment with correct bytes works. -- **It forecloses the legitimate case** in §2.3: a fragment assembled once at startup from configuration. - -Keeping `SER011` out of `NoWarn` is the right amount of friction instead. Blanket-disabling it means +- **It forecloses the legitimate case** in §2.3 — a fragment assembled once at startup from + configuration. This is the decisive one: an absolute block needs the sanctioned alternative to exist + *first*, meaning a runtime-validating factory. Closing the door without opening another one leads to + forks, or to the feature being avoided. +- **`#error` has no squiggle and no code fix.** The build fails with text; there is nothing at the + offending call site for the IDE to show. That is a poor trade for a rule people meet while learning the + API. + +Keeping `SER011` out of `NoWarn` is the proportionate friction instead. Blanket-disabling it means adding it to the csproj, where review sees it; per-site suppression means a pragma naming `SER011`, which makes `git grep SER011` an exact inventory of every hand-rolled fragment in a codebase. Auditable beats absent, for a failure mode whose whole problem is invisibility. If more teeth are wanted, the From deefe643611447b9307042286eb984e35b1c15c7 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 21:26:48 +0100 Subject: [PATCH 028/360] Both: the diagnostic teaches, the #error guarantees They are not alternatives. The [Experimental] diagnostic gives a squiggle at the call site, a link and a code fix; the generator-emitted #error makes the build fail regardless of suppression. The weaknesses cancel. The generator does not need to know whether the diagnostic was suppressed - it emits #error for every construction it did not emit itself, so suppressing SER011 removes the squiggle and changes nothing about the outcome. That is honest: the suppression stops claiming to achieve something it does not. This only works with a sanctioned escape hatch, and it has to exist first: a RespFragment.CreateValidated(bytes, argCount) that checks framing at runtime and that the generator ignores. Then "you cannot hand-roll a fragment" is true and has a supported answer for the startup-built case, rather than being a dead end. Without it, the absolute block is the hostile version. Notes that the #error half is a design rather than a demonstration, since the generator does not exist yet; what was verified is the part it depends on, that a #error anywhere in the compilation cannot be suppressed. --- design/interpolated-resp-writer.md | 37 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3527dbb95..06c3a990a 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1155,21 +1155,38 @@ Two objections that look like blockers are not: constructions are its own, because it emitted them. That is identity, not an `` header check. -So the reasons not to are about judgement rather than mechanism: +**Do both.** They are not alternatives — they do different jobs, and the weaknesses cancel: + +| | Job | Suppressible | +| --- | --- | --- | +| `[Experimental(SER011)]` | squiggle at the call site, link, code fix — teaches | yes | +| generator emits `#error` | the build fails regardless — guarantees | no | + +The generator does not need to know whether the diagnostic was suppressed; it emits `#error` for every +construction it did not emit itself. So suppressing `SER011` removes the squiggle and changes nothing +about the outcome, which is honest — the suppression stops claiming to achieve something it does not. + +**This only works with a sanctioned escape hatch, and it has to exist first.** A +`RespFragment.CreateValidated(bytes, argCount)` that checks framing at runtime, which the generator +ignores. Then "you cannot hand-roll a fragment" is a true statement with a supported answer for the +startup-built case from §2.3, rather than a dead end. Without it, the absolute block is the hostile +version. + +So the reasons to be careful are about judgement rather than mechanism: - **It fails this repo's own bar for an error.** `Diagnostics.cs` reserves `DiagnosticSeverity.Error` for code that *cannot work* — "there is no arrangement of the surrounding code that makes it work" — and warns that "an error that can be wrong is a broken build on correct code, which is a far worse trade than a warning that can be wrong". A hand-built fragment with correct bytes works. -- **It forecloses the legitimate case** in §2.3 — a fragment assembled once at startup from - configuration. This is the decisive one: an absolute block needs the sanctioned alternative to exist - *first*, meaning a runtime-validating factory. Closing the door without opening another one leads to - forks, or to the feature being avoided. -- **`#error` has no squiggle and no code fix.** The build fails with text; there is nothing at the - offending call site for the IDE to show. That is a poor trade for a rule people meet while learning the - API. - -Keeping `SER011` out of `NoWarn` is the proportionate friction instead. Blanket-disabling it means +- **It forecloses the legitimate case** in §2.3 unless `CreateValidated` ships alongside it — which is + why that is a precondition rather than a nicety. + +Not yet built: the generator does not exist (§2.3 spikes the pattern with both halves hand-written), so +the `#error` half is a design, not a demonstration. What *was* verified is the part it depends on — that +a `#error` anywhere in the compilation cannot be suppressed. + +Until then, keeping `SER011` out of `NoWarn` is the proportionate friction: blanket-disabling means +adding it to the csproj where review sees it, and per-site suppression means a pragma naming `SER011`. Blanket-disabling it means adding it to the csproj, where review sees it; per-site suppression means a pragma naming `SER011`, which makes `git grep SER011` an exact inventory of every hand-rolled fragment in a codebase. Auditable beats absent, for a failure mode whose whole problem is invisibility. If more teeth are wanted, the From 44e3575bd9e246cc6db0f90b18edcddb5ca334dc Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 22:04:31 +0100 Subject: [PATCH 029/360] #line lets the generator put the #error at the call site Corrects the last objection, which was that #error has no squiggle at the offending site. #line redirects it: both the classic form and the C# 10 span form report into a file the generator never wrote, verified against a Consumer.cs that does not exist. #line 42 "Consumer.cs" -> Consumer.cs(42,8) #line (12, 34) - (12, 58) 1 "Consumer.cs" -> Consumer.cs(12,40) The span form is the one added in C# 10 for generators. The mapped column derives from the physical column in the generated file adjusted by charOffset - asking for 34 gave 40 - so landing it exactly means laying the emitted line out deliberately rather than just stating offsets. Since generators run in the IDE, that gives a live squiggle in the right place, leaving the code fix as the analyzer's only unique contribution. Also records the argument that settles the tension with this repo's rule of reserving Error for code that cannot work: the blast radius here is not the caller's own code. Malformed RESP desyncs the connection for every subsequent command, so the damage is unbounded and lands somewhere unrelated - a different class of hazard from getting a wrong answer, and what justifies the asymmetry. --- design/interpolated-resp-writer.md | 34 +++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 06c3a990a..202902cde 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1159,25 +1159,43 @@ Two objections that look like blockers are not: | | Job | Suppressible | | --- | --- | --- | -| `[Experimental(SER011)]` | squiggle at the call site, link, code fix — teaches | yes | -| generator emits `#error` | the build fails regardless — guarantees | no | +| `[Experimental(SER011)]` | code fix — "convert to a `[Resp]` partial property" | yes | +| generator emits `#error` | the build fails regardless | no | The generator does not need to know whether the diagnostic was suppressed; it emits `#error` for every construction it did not emit itself. So suppressing `SER011` removes the squiggle and changes nothing about the outcome, which is honest — the suppression stops claiming to achieve something it does not. +**`#error` is not stuck at "right here":** `#line` redirects it, so the generator can report *at the +offending call site*, in a file it never wrote. Both forms verified, reporting into a `Consumer.cs` that +does not exist: + +```csharp +#line 42 "Consumer.cs" // -> Consumer.cs(42,8) +#line (12, 34) - (12, 58) 1 "Consumer.cs" // -> Consumer.cs(12,40), the C# 10 span form +#error RespFragment constructed by hand; see https://seredis.dev/exp/SER011 +#line default +``` + +The span form is the one added in C# 10 for generators. Note the mapped column derives from the physical +column in the *generated* file adjusted by the `charOffset` argument — asking for column 34 gave 40 — so +landing it exactly means laying the emitted line out deliberately, not merely stating the offsets. + +Since generators run in the IDE, this gives a live squiggle in the right place, which leaves the code fix +as the analyzer's only unique contribution. + **This only works with a sanctioned escape hatch, and it has to exist first.** A `RespFragment.CreateValidated(bytes, argCount)` that checks framing at runtime, which the generator ignores. Then "you cannot hand-roll a fragment" is a true statement with a supported answer for the startup-built case from §2.3, rather than a dead end. Without it, the absolute block is the hostile version. -So the reasons to be careful are about judgement rather than mechanism: - -- **It fails this repo's own bar for an error.** `Diagnostics.cs` reserves `DiagnosticSeverity.Error` for - code that *cannot work* — "there is no arrangement of the surrounding code that makes it work" — and - warns that "an error that can be wrong is a broken build on correct code, which is a far worse trade - than a warning that can be wrong". A hand-built fragment with correct bytes works. +The one remaining tension is with this repo's own bar for an error. `Diagnostics.cs` reserves +`DiagnosticSeverity.Error` for code that *cannot work*, and a hand-built fragment with correct bytes +works. The counter-argument, which carries here: **the blast radius is not the caller's own code.** +Malformed RESP desyncs the connection for every *subsequent* command, so the damage is unbounded and +lands somewhere unrelated. That is a different class of hazard from "you get a wrong answer", and it is +what justifies the asymmetry. - **It forecloses the legitimate case** in §2.3 unless `CreateValidated` ships alongside it — which is why that is a precondition rather than a nicety. From 55b55039cdca38b2392ec3f35a8f2203ed5b9a8d Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 22:22:06 +0100 Subject: [PATCH 030/360] Benchmark the writer against the existing paths Formatting only - no server, no dispatch - rendering the same command three ways into the same pre-allocated IBufferWriter, on net10.0: KeyValue_Message 63.97 ns 136 B 1.00 Message.Create + WriteTo KeyValue_Adhoc 89.69 ns 152 B 1.40 ExecuteMessage over object[] KeyValue_Interpolated 41.72 ns 0 B 0.65 this Expiry_Message 90.91 ns 168 B 1.00 four arguments Expiry_Interpolated 69.83 ns 0 B 0.77 four arguments About a third faster than the typed path that db.StringSet uses, and twice as fast as the ad-hoc string path - which is the relevant comparison for the public string overload, since that is what it competes with - with no managed allocation where both existing paths allocate 136 to 168 bytes per command. Two qualifications are recorded alongside the numbers. The comparison is conservative on time, because the interpolated path renders into a rented buffer and then copies into the target while Message writes straight through. And the zero allocation will not survive dispatch: Message allocates partly because it is retained for the response, so a dispatching implementation needs per-command state too. What the zero establishes is that formatting itself need not allocate, which is the half this replaces. Note TestHarness.Write could not be used for the ad-hoc row - it hardcodes db -1 and SET requires a database - so ExecuteMessage is constructed directly with db 0. --- design/interpolated-resp-writer.md | 28 +++++ .../InterpolatedWriterBenchmarks.cs | 111 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 202902cde..3a0e8f964 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1234,6 +1234,34 @@ consistent with the existing ad-hoc command path rather than a second set of rul --- +### 9.2 Measured against the existing writer + +`InterpolatedWriterBenchmarks` compares rendering the same command three ways, formatting only — no +server, no dispatch — all writing into the same pre-allocated `IBufferWriter`: + +| Method | What it is | Mean | Allocated | Ratio | +| --- | --- | ---: | ---: | ---: | +| `KeyValue_Message` | `Message.Create` + `WriteTo` — the typed path `db.StringSet` uses | 63.97 ns | 136 B | 1.00 | +| `KeyValue_Adhoc` | `ExecuteMessage` over `object[]` — what `Execute(string, ...)` does | 89.69 ns | 152 B | 1.40 | +| `KeyValue_Interpolated` | this | **41.72 ns** | **0 B** | **0.65** | +| `Expiry_Message` | four arguments, typed path | 90.91 ns | 168 B | 1.00 | +| `Expiry_Interpolated` | four arguments, this | **69.83 ns** | **0 B** | **0.77** | + +So roughly **a third faster than the typed path and twice as fast as the ad-hoc string path**, with no +managed allocation where both existing paths allocate 136-168 bytes per command. The ad-hoc row is the +relevant comparison for the public string overload (§9.1), since that is what it competes with. + +Two honest qualifications: + +- **The comparison is conservative on time.** The interpolated path renders into a rented buffer and then + copies into the target; `Message` writes straight through. Removing that copy would widen the gap. +- **The 0 B will not survive dispatch.** `Message` allocates partly because it is *retained* for the + response. A dispatching implementation needs per-command state too, so the end-to-end delta will not + stay 136 B → 0. What the zero does establish is that *formatting itself* need not allocate, which is + the half this replaces. + +--- + ## 10. Open questions - **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which diff --git a/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs new file mode 100644 index 000000000..19ab0952b --- /dev/null +++ b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs @@ -0,0 +1,111 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using StackExchange.Redis.Interpolated; + +namespace StackExchange.Redis.Benchmarks; + +// Formatting only - no server, no dispatch. The interpolated writer replaces the WRITE half, so this +// compares rendering the same command three ways: +// +// Message - the mainstream typed path: Message.Create + WriteTo(MessageWriter). What db.StringSet does. +// Adhoc - the ad-hoc string path: ExecuteMessage over object[], via TestHarness. What +// IDatabase.Execute(string, ...) does, and what the new string overload competes with. +// Interpolated - the new path. +// +// All three write into the same pre-allocated IBufferWriter, so the buffer itself is not being measured; +// what differs is the boxing, the intermediate argument arrays, and whether anything is re-materialised. +[Config(typeof(CustomConfig))] +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class InterpolatedWriterBenchmarks +{ + private readonly RedisKey _key = "user:1"; + private readonly RedisValue _value = "marc"; + private readonly Reusable _target = new(); + private RespContext _ctx; + private object[] _adhocArgs = null!; + private RedisValue[] _multiValues = null!; + + [GlobalSetup] + public void Setup() + { + _ctx = new RespContext(); + _adhocArgs = new object[] { "user:1", "marc" }; + _multiValues = new RedisValue[] { "marc", "EX", 300 }; + } + + // ---- SET key value ----------------------------------------------------------------------------- + + [BenchmarkCategory("KeyValue"), Benchmark(Baseline = true)] + public int KeyValue_Message() + { + _target.Reset(); + var msg = Message.Create(0, CommandFlags.None, RedisCommand.SET, _key, _value); + msg.WriteTo(new MessageWriter(null, CommandMap.Default, _target)); + return _target.Written; + } + + [BenchmarkCategory("KeyValue"), Benchmark] + public int KeyValue_Adhoc() + { + _target.Reset(); + var msg = new RedisDatabase.ExecuteMessage(CommandMap.Default, 0, CommandFlags.None, "SET", _adhocArgs); + msg.WriteTo(new MessageWriter(null, CommandMap.Default, _target)); + return _target.Written; + } + + [BenchmarkCategory("KeyValue"), Benchmark] + public int KeyValue_Interpolated() + { + using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value}"); + _target.Reset(); + _target.Write(frame.Span); + return _target.Written; + } + + // ---- SET key value EX 300 (four arguments) ----------------------------------------------------- + + [BenchmarkCategory("Expiry"), Benchmark(Baseline = true)] + public int Expiry_Message() + { + _target.Reset(); + var msg = Message.Create(0, CommandFlags.None, RedisCommand.SET, _key, _multiValues); + msg.WriteTo(new MessageWriter(null, CommandMap.Default, _target)); + return _target.Written; + } + + [BenchmarkCategory("Expiry"), Benchmark] + public int Expiry_Interpolated() + { + using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); + _target.Reset(); + _target.Write(frame.Span); + return _target.Written; + } + + /// A trivial reusable buffer writer, so buffer management is not part of the measurement. + private sealed class Reusable : IBufferWriter + { + private readonly byte[] _buffer = new byte[4096]; + + public int Written { get; private set; } + + public void Reset() => Written = 0; + + public void Advance(int count) => Written += count; + + public Memory GetMemory(int sizeHint = 0) => new(_buffer, Written, _buffer.Length - Written); + + public Span GetSpan(int sizeHint = 0) => new(_buffer, Written, _buffer.Length - Written); + + public void Write(ReadOnlySpan value) + { + value.CopyTo(new Span(_buffer, Written, _buffer.Length - Written)); + Written += value.Length; + } + } +} From a4dd21e8bc47295aca584d60573bc52b2eacbc0d Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 22:29:58 +0100 Subject: [PATCH 031/360] Inline literal tokens: reject, with a diagnostic and a fixer Settles the question of whether $"{key} nx {val} withsave" should split literal runs into arguments. It should not; the analyzer carries the ergonomics instead, with a diagnostic on the literal and a code fixer that rewrites it: $"{key} nx {val}" -> $"{key} {RespLiterals.Nx} {val}" Two fixes, since the token may not be declared yet: use the existing [Resp] member, or declare it and use it, letting the generator fill in the body. You type it the natural way and take the fix; the committed code is the strict form, so the gap closes at authoring time, which is where it is felt. Records why not to make it work at runtime. It is achievable, and the resolution need not be a lazy dictionary - this repo already generates that lookup as a hash-dispatched switch via [AsciiHash(CaseSensitive = false)] on a partial method, allocation-free and case-insensitive, so nx would still yield canonical NX bytes. The cost is elsewhere: inline tokens are literal segments rather than holes, so formattedCount stops being the argument count and *N stops being a compile-time constant on every call site using the sugar; and it is a second mechanism to document, analyze and explain alongside RespFragment. Also notes that the analyzer and the generator are one piece of work rather than two, since both are driven by the set of [Resp] declarations: the generator emits bodies from them, and the analyzer and fixer need the same set to validate literals and to know which member to offer. --- design/interpolated-resp-writer.md | 41 +++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3a0e8f964..68e60e3e1 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -158,6 +158,43 @@ The cost is the compile-time guarantee, discussed above: enforcement moves to a analyzer. Formatters are a third hazard alongside the two already noted — nothing stops a tool normalising whitespace inside an interpolated string. +#### Inline tokens: rejected, with a fixer + +`$"{key} nx {val} withsave"` reads well, and `nx`/`withsave` are arguments rather than separators — so the +question is whether literal runs should be split into tokens. **No.** They stay rejected, and the analyzer +carries the ergonomics instead: a diagnostic on the offending literal plus a **code fixer** that rewrites +it to the declared form. + +``` +$"{key} nx {val}" -> fix -> $"{key} {RespLiterals.Nx} {val}" +``` + +Two fixes, since the token may not be declared yet: + +- *"Use `RespLiterals.Nx`"* when a matching `[Resp]` declaration exists. +- *"Declare `Nx` and use it"* when it does not — the fixer adds the partial property, and the generator + fills in the body. + +You type it the natural way and take the fix; the committed code is the strict form. The ergonomic gap +closes at authoring time, which is where it is actually felt. + +**Why not make it work at runtime.** It is achievable: `AppendLiteral(" nx ")` could split on spaces and +resolve each token, and the resolution need not be a lazy dictionary — this repo already generates +precisely that lookup as a hash-dispatched switch, `[AsciiHash(CaseSensitive = false)] static partial bool +TryParseCI(...)`, which is allocation-free, lock-free, and case-insensitive (so `nx` in source would still +yield the canonical `NX` bytes). The cost is not the lookup: + +- **`*N` stops being a compile-time constant.** Inline tokens are literal segments, not holes, so + `formattedCount` is no longer the argument count — and the loss applies to every call site using the + sugar, not only the complex ones. +- **It is a second mechanism** to document, analyze and explain, alongside `RespFragment`. + +`{Nx}` costs a declaration; `nx` costs an invariant. + +**The analyzer and the generator are one piece of work**, not two: both are driven by the set of `[Resp]` +declarations. The generator emits the fragment bodies from them; the analyzer validates literals against +the same set, and the fixer needs it to know which member to offer. + **Non-interpolated strings do *not* bind to the handler.** If a `string` overload exists alongside, `Write(buf, "plain literal")` silently takes it while `Write(buf, $"GET {key}")` takes the handler. Either don't provide a `string` overload, or accept that callers must write `$"PING"`. @@ -918,7 +955,9 @@ Rules: 5. **No keys inside a `Raw`** — invisible to the handler, so they would never be registered for invalidation or counted for routing. 6. **Shape** — at least one hole; first hole is a `RedisCommand`. -7. Possibly: a better diagnostic than `CS1503` for an unsupported hole type. +7. **Inline literal tokens** — reject, with a fixer offering `{RespLiterals.Nx}` (or offering to declare + it first). See §2.1. +8. Possibly: a better diagnostic than `CS1503` for an unsupported hole type. Rules 1–3 have mechanical code fixes, which is presumably what the CodeFixes assembly is for. From 5a7af4d639dc73fb27bb01fafdb80d248676402c Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:25:16 +0100 Subject: [PATCH 032/360] AppendLiteral becomes a no-op; enforcement is the analyzer's alone Removes the runtime check. AppendLiteral is now empty, so the JIT eliminates the call entirely. The reasoning is blast radius, not laziness. Discarding a literal is benign in the way that matters: the frame stays WELL-FORMED with an argument missing. Literals never contributed to *N, so the header remains correct; the server sees a wrong command and errors, or does the wrong thing, and the connection is unaffected. A runtime check would buy nothing the analyzer does not, since someone who ignores an analyzer error has made a local mistake rather than a protocol-damaging one. That contrasts with RespFragment under SER011, where bad bytes desync the connection for every subsequent command - which is what earns the stronger guard there. The principle now runs through both: guard strength proportional to blast radius, an analyzer error here, an analyzer error plus a generator-emitted #error there. Tests updated to document the new behaviour rather than assert a throw: two spaces and a hyphen are discarded and the frame still parses as GET k with the correct argument count. A separate test keeps the case that IS still caught - $"SET {key}" discards the literal, so no command was ever supplied, and the handler cannot frame a key before it has one. Adds a Separators benchmark pair, spaced versus unspaced, to check that the empty method really does vanish. 59 tests, green on net10.0 and net8.0, Release analyzers clean. --- design/interpolated-resp-writer.md | 29 +++++++++++++------ .../Interpolated/RespCommandHandler.cs | 18 +++++++----- .../InterpolatedWriterBenchmarks.cs | 18 ++++++++++++ .../InterpolatedWriterUnitTests.cs | 25 ++++++++++++---- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 68e60e3e1..a8d5b2658 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -87,7 +87,7 @@ ctx.Execute($"{cmd} {key}") // rejected - two spaces ``` The space earns its place on readability alone: `$"{RedisCommand.SET} {key} {value}"` mirrors how the -command is written everywhere else, and costs 0.45 ns (measured below). +command is written everywhere else, and costs nothing — `AppendLiteral` is an empty method. **Why reject the rest:** with no literal segments, the compiler-supplied `formattedCount` *is* the argument count, as a compile-time constant — so `*N\r\n` can be written in the constructor with no counting @@ -121,7 +121,18 @@ of a `u8` route is one more reason to ban literals rather than encode them at ru ("Traditional method invocation resolution" is also why the ban holds: see below.) -**Enforcement is a runtime check, and wants an analyzer.** An earlier revision marked +**Enforcement is the analyzer's, exclusively. `AppendLiteral` is a no-op with no check at all**, so the +JIT eliminates the call. That is not laziness — a runtime check would buy nothing the analyzer does not, +because *discarding a literal is benign in the way that matters*: the frame stays **well-formed**, with +an argument missing. Literals never contributed to `*N`, so the header remains correct; the server sees +a wrong command and errors, or does the wrong thing, and the connection is unaffected. + +Compare `RespFragment` (§9.1), where bad bytes desync the connection for every *subsequent* command. The +principle running through both: **guard strength proportional to blast radius** — an analyzer error here, +an analyzer error *plus* a generator-emitted `#error` there. Ignoring the analyzer here is user error with +local consequences; ignoring it there corrupts other people's commands. + +An earlier revision marked `AppendLiteral(string)` as `[Obsolete(..., error: true)]`, which made *any* literal a compile error — strictly stronger, but incompatible with allowing the space. Two findings from that revision, recorded because they bear on the alternative: @@ -149,14 +160,14 @@ tight : args=3 literals=0 formattedCount=3 literalLength=0 spaced : args=3 literals=2 formattedCount=3 literalLength=2 ``` -**Nor does it cost anything measurable.** 0.45 ns per space, against an 8.6 ns baseline of pure handler -machinery in a synthetic loop that does no buffer work at all; a real render is tens to hundreds of ns, -and the operation around it is orders beyond that. Both spellings also render byte-identically, since -the space is discarded — so cache identity (§6.2) is unaffected. +**Nor does it cost anything.** With the check removed, `AppendLiteral` is empty and the call is +eliminated — see the `Separators` benchmark. (It cost 0.45 ns per space while the runtime check existed.) +Both spellings render byte-identically, since the space is discarded, so cache identity (§6.2) is +unaffected. -The cost is the compile-time guarantee, discussed above: enforcement moves to a runtime check plus the -analyzer. Formatters are a third hazard alongside the two already noted — nothing stops a tool -normalising whitespace inside an interpolated string. +The cost is the compile-time guarantee, discussed above: enforcement rests entirely on the analyzer. +Formatters are a third hazard alongside the two already noted — nothing stops a tool normalising +whitespace inside an interpolated string. #### Inline tokens: rejected, with a fixer diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index c148422da..29ca745da 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -136,15 +136,19 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con /// public void AppendLiteral(string value) { - if (value is not " ") ThrowNotSeparator(value); + // Deliberately empty, with no check: the JIT eliminates the call entirely. + // + // Enforcement belongs to the analyzer, which reports literal text as an ERROR and offers a fix + // rewriting it to a declared fragment. A runtime check would buy nothing the analyzer does not, + // because the failure mode here is benign in the way that matters: a discarded literal produces + // a WELL-FORMED frame with an argument missing. The server errors, or does the wrong thing, and + // the connection is unaffected - literals never contributed to *N, so the header stays correct. + // + // Contrast RespFragment (SER011), where bad bytes desync the connection for every subsequent + // command. Guard strength is proportional to blast radius: analyzer error here, analyzer plus a + // generator-emitted #error there. } - [MethodImpl(MethodImplOptions.NoInlining)] - private static void ThrowNotSeparator(string value) => throw new ArgumentException( - $"Only a single space may separate arguments; every other part must be a hole. Saw \"{value}\". " - + "Write $\"{RedisCommand.SET} {key} {value}\", not $\"SET {key} {value}\".", - nameof(value)); - internal void AppendFormatted(RedisCommand value) { if (_hasCommand) throw new InvalidOperationException("The command must be the first argument, and may only be given once."); diff --git a/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs index 19ab0952b..deaacba63 100644 --- a/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs @@ -87,6 +87,24 @@ public int Expiry_Interpolated() return _target.Written; } + // ---- does the no-op AppendLiteral actually vanish? --------------------------------------------- + // Separators are literal segments, discarded by an empty AppendLiteral. If the JIT eliminates the + // call, these two are the same work; any gap is what the readable form costs. + + [BenchmarkCategory("Separators"), Benchmark(Baseline = true)] + public int Separators_None() + { + using var frame = _ctx.Execute(RedisCommand.SET, $"{_key}{_value}{(RedisValue)"EX"}{(RedisValue)300}"); + return frame.ArgCount; + } + + [BenchmarkCategory("Separators"), Benchmark] + public int Separators_Spaced() + { + using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); + return frame.ArgCount; + } + /// A trivial reusable buffer writer, so buffer management is not part of the measurement. private sealed class Reusable : IBufferWriter { diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 78e98cc1f..241752bf4 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -471,13 +471,28 @@ public void SingleSpacesAreAllowedAndDiscarded() } [Fact] - public void OtherLiteralsAreRejected() + public void LiteralsAreDiscardedNotRejectedAtRuntime() { + // AppendLiteral is a no-op: rejection is the ANALYZER's job, as an error with a fix. A runtime + // check would add nothing, because discarding a literal leaves a well-formed frame with an + // argument missing - the command is wrong, but the connection is not. Literals never contributed + // to *N, so the header stays correct either way. var ctx = new RespContext(); - // two spaces look identical to one on the page; this is why the analyzer has to carry the rule - Assert.Throws(() => ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}").Dispose()); - Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}-{(RedisKey)"k"}").Dispose()); - Assert.Throws(() => ctx.Execute($"SET {(RedisKey)"k"}").Dispose()); + using var twoSpaces = ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}"); + using var hyphen = ctx.Execute($"{RedisCommand.GET}-{(RedisKey)"k"}"); + + Assert.Equal(new[] { "GET", "k" }, Parse(twoSpaces.Span)); + Assert.Equal(new[] { "GET", "k" }, Parse(hyphen.Span)); + Assert.Equal(2, twoSpaces.ArgCount); + } + + [Fact] + public void ALiteralCommandStillFailsBecauseThereIsNoCommandHole() + { + // $"SET {key}" discards "SET ", so nothing ever supplied a command - which IS caught, because the + // handler cannot frame a key before it has one + var ctx = new RespContext(); + Assert.Throws(() => ctx.Execute($"SET {(RedisKey)"k"}").Dispose()); } } From fdd929f161ecb3ceb1561d942f5dc882a8b8c79e Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:27:39 +0100 Subject: [PATCH 033/360] Add RespFragmentGenerator: implement [Resp] partial properties Emits the body for [Resp] partial RespFragment properties, so framing, length prefixes, casing and the argument count are correct by construction rather than by review - which is the whole reason hand-written fragments are gated behind SER011. Casing follows the rule already recorded: a token taken from the member name is upper-cased, a token given in the attribute is used verbatim. The fragment test now declares only the properties, and the generator supplies the bodies; its exact-frame assertions pass unchanged, which is the real check - EX inferred and upper-cased, lib-name verbatim, SETINFO lib-name counted as two arguments. The emitted file suppresses SER010 and SER011 at the source, and nowhere wider, since the generator is the sanctioned construction site. Two notes from building it. The analyzer project targets netstandard2.0 and an older Roslyn, so no records (no IsExternalInit) and LanguageVersion.CSharp11 has to come from the existing LanguageVersions shim. Also records the Separators benchmark result, which did not go as predicted: the empty AppendLiteral does NOT fully vanish. Spaced renders at 66.87 ns against 62.62 ns unspaced, a ratio of 1.07, consistent across both jobs - roughly 1.4 ns per space, the ldstr and call not being eliminated. It does not change the decision, which rests on blast radius rather than cost, but "the JIT nukes it" is about 7% of the render rather than zero. --- .../RespFragmentGenerator.cs | 244 ++++++++++++++++++ .../InterpolatedWriterFragmentTests.cs | 29 +-- 2 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 eng/StackExchange.Redis.Build/RespFragmentGenerator.cs diff --git a/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs b/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs new file mode 100644 index 000000000..a61f1c18f --- /dev/null +++ b/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace StackExchange.Redis.Build; + +/// +/// Implements [Resp] partial RespFragment Foo { get; } by emitting the body: the tokens, pre-framed as +/// RESP bulk strings, with the argument count that goes with them. +/// +/// +/// +/// The point is that framing, length prefixes, casing and the argument count are correct by construction +/// rather than by review. Hand-written fragments are gated behind SER011 precisely because none of that is +/// checkable at the point of use, and malformed bytes desync the connection for every subsequent command. +/// +/// +/// Casing: a token taken from the member name is upper-cased, which is right for ~84% of the tokens this +/// library sends; a token given in the attribute is used verbatim, because the rest are values rather than +/// keywords (yes, lib-name, replica, the geo units) and lower-case is what goes on the +/// wire today. +/// +/// +[Generator(LanguageNames.CSharp)] +public class RespFragmentGenerator : IIncrementalGenerator +{ + private const string RespAttributeName = "StackExchange.Redis.Interpolated.RespAttribute"; + private const string FragmentType = "global::StackExchange.Redis.Interpolated.RespFragment"; + + /// u8 literals need C# 11; below that we say so rather than emitting code that cannot compile. + private const LanguageVersion MinimumLanguageVersion = LanguageVersions.CSharp11; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var fragments = context.SyntaxProvider + .ForAttributeWithMetadataName( + RespAttributeName, + static (node, _) => node is PropertyDeclarationSyntax decl + && decl.Modifiers.Any(SyntaxKind.PartialKeyword), + Transform) + .Where(static x => x is not null) + .Collect(); + + var languageVersion = context.ParseOptionsProvider.Select(static (options, _) + => options is CSharpParseOptions cs ? cs.LanguageVersion.MapSpecifiedToEffectiveVersion() : LanguageVersion.Latest); + + context.RegisterSourceOutput(fragments.Combine(languageVersion), static (ctx, content) => + { + var (found, version) = (content.Left, content.Right); + if (found.IsDefaultOrEmpty) return; + + if (version < MinimumLanguageVersion) + { + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.LanguageVersionTooLow, null, version.ToDisplayString(), MinimumLanguageVersion.ToDisplayString())); + return; + } + + Emit(ctx, found!); + }); + } + + private static FragmentInfo? Transform(GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) + { + if (context.TargetSymbol is not IPropertySymbol property) return null; + if (property.Type.ToDisplayString() != "StackExchange.Redis.Interpolated.RespFragment") return null; + + var tokens = ImmutableArray.Empty; + foreach (var attribute in context.Attributes) + { + var flat = new List(); + foreach (var arg in attribute.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var element in arg.Values) + { + if (element.Value is string s) flat.Add(s); + } + } + else if (arg.Value is string s) + { + flat.Add(s); + } + } + + tokens = flat.ToImmutableArray(); + } + + // no tokens given: infer one from the member name, upper-cased (see the remarks on this type) + if (tokens.IsEmpty) tokens = ImmutableArray.Create(property.Name.ToUpperInvariant()); + + var containers = new List(); + for (var type = property.ContainingType; type is not null; type = type.ContainingType) + { + containers.Insert(0, DeclarationOf(type)); + } + + return new FragmentInfo( + property.ContainingNamespace.IsGlobalNamespace ? null : property.ContainingNamespace.ToDisplayString(), + containers.ToImmutableArray(), + property.Name, + AccessibilityOf(property.DeclaredAccessibility), + property.IsStatic, + tokens); + } + + private static string DeclarationOf(INamedTypeSymbol type) + { + var kind = type.TypeKind switch + { + TypeKind.Struct => type.IsRecord ? "record struct" : "struct", + TypeKind.Interface => "interface", + _ => type.IsRecord ? "record" : "class", + }; + return $"partial {kind} {type.Name}"; + } + + private static string AccessibilityOf(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private", + }; + + private static void Emit(SourceProductionContext ctx, ImmutableArray fragments) + { + var sb = new StringBuilder("// ").AppendLine() + .Append("// ").Append(nameof(RespFragmentGenerator)).AppendLine() + .AppendLine("#nullable enable") + // SER010: the feature is experimental; SER011: hand-constructing a fragment. We ARE the sanctioned + // construction site, so the suppression belongs here and nowhere wider. + .AppendLine("#pragma warning disable SER010, SER011"); + + var writer = new CodeWriter(sb); + foreach (var group in fragments.Where(f => f is not null) + .Select(f => f!) + .GroupBy(f => (f.Namespace, Containers: string.Join("+", f.Containers)))) + { + var first = group.First(); + var depth = 0; + + if (first.Namespace is { Length: > 0 }) + { + writer.Append("namespace ").Append(first.Namespace).NewLine().Append("{").NewLine().Indent(); + depth++; + } + + foreach (var container in first.Containers) + { + writer.Append(container).NewLine().Append("{").NewLine().Indent(); + depth++; + } + + foreach (var fragment in group) + { + WriteFragment(writer, fragment); + } + + while (depth-- > 0) + { + writer.Outdent().Append("}").NewLine(); + } + } + + ctx.AddSource("RespFragments.generated.cs", sb.ToString()); + } + + private static void WriteFragment(CodeWriter writer, FragmentInfo fragment) + { + var bytes = new StringBuilder(); + foreach (var token in fragment.Tokens) + { + bytes.Append('$').Append(Encoding.UTF8.GetByteCount(token)).Append("\\r\\n").Append(Escape(token)).Append("\\r\\n"); + } + + writer.Append("/// "); + for (int i = 0; i < fragment.Tokens.Length; i++) + { + if (i != 0) writer.Append(' '); + writer.Append("").Append(EscapeXml(fragment.Tokens[i])).Append(""); + } + + writer.Append(fragment.Tokens.Length == 1 ? ", as a RESP bulk string." : ", as RESP bulk strings.").Append("").NewLine(); + + writer.Append(fragment.Accessibility).Append(' '); + if (fragment.IsStatic) writer.Append("static "); + writer.Append("partial ").Append(FragmentType).Append(' ').Append(fragment.Name) + .Append(" => new(\"").Append(bytes.ToString()).Append("\"u8"); + if (fragment.Tokens.Length != 1) writer.Append(", ").Append(fragment.Tokens.Length); + writer.Append(");").NewLine().NewLine(); + } + + private static string Escape(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\r': sb.Append("\\r"); break; + case '\n': sb.Append("\\n"); break; + default: sb.Append(c); break; + } + } + + return sb.ToString(); + } + + private static string EscapeXml(string value) + => value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + + /// What one declaration needs for its body to be emitted. + /// Not a record: this project targets netstandard2.0, which has no IsExternalInit. + private sealed class FragmentInfo( + string? ns, + ImmutableArray containers, + string name, + string accessibility, + bool isStatic, + ImmutableArray tokens) + { + public string? Namespace => ns; + + public ImmutableArray Containers => containers; + + public string Name => name; + + public string Accessibility => accessibility; + + public bool IsStatic => isStatic; + + public ImmutableArray Tokens => tokens; + } +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index ee404b725..cd5ae932c 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -11,11 +11,10 @@ namespace StackExchange.Redis.Tests; /// would emit. Both halves are hand-written here - the point is the usage, not the generator. /// See design/interpolated-resp-writer.md section 2.3. /// -public class InterpolatedWriterFragmentTests +public partial class InterpolatedWriterFragmentTests { - // ---- half 1: what the AUTHOR writes ----------------------------------------------------------- - // The attribute is only needed when the token differs from the member name, or when the fragment - // spans more than one token. + // The author writes the declaration; RespFragmentGenerator emits the body. The attribute is only + // needed when the token differs from the member name, or when the fragment spans more than one token. internal static partial class RespLiterals { @@ -44,27 +43,7 @@ internal static partial class RespLiterals internal static partial RespFragment LeftRight { get; } } - // ---- half 2: what the GENERATOR would emit ---------------------------------------------------- - // - // Constructing a RespFragment by hand is gated behind SER011 precisely because nothing validates the - // bytes. Generated code suppresses it AT THE EMIT SITE and nowhere wider, which is what this shows. - // A token inferred from the member name is upper-cased; a token given in the attribute is verbatim, - // because the library sends both cases and the distinction is semantic - see the design notes. - -#pragma warning disable SER011 // hand-constructed RespFragment: this half stands in for the generator - internal static partial class RespLiterals - { - internal static partial RespFragment EX => new("$2\r\nEX\r\n"u8); - - internal static partial RespFragment ConfigGet => new("$3\r\nGET\r\n"u8); - - internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nlib-name\r\n"u8, 2); - - internal static partial RespFragment MaxLenApprox => new("$6\r\nMAXLEN\r\n$1\r\n~\r\n"u8, 2); - - internal static partial RespFragment LeftRight => new("$4\r\nLEFT\r\n$5\r\nRIGHT\r\n"u8, 2); - } -#pragma warning restore SER011 + // half 2 - the bodies - is emitted by RespFragmentGenerator from the declarations above. private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); From 0489f4d0d8db75fe18da887ec96269fe7c612f54 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:27:48 +0100 Subject: [PATCH 034/360] Correct the separator cost: ~1.4ns per space, not zero The doc claimed the empty AppendLiteral was eliminated outright. The Separators benchmark says otherwise: 66.87 ns spaced against 62.62 ns unspaced, a ratio of 1.07, consistent across both jobs - so the ldstr and call survive despite the body being empty. About 7% of the render, and much less of the operation around it, but not the zero that was claimed. --- design/interpolated-resp-writer.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index a8d5b2658..6c9c964bb 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -86,8 +86,8 @@ ctx.Execute($"SET {key} {value}") // rejected - "SET " is not a ctx.Execute($"{cmd} {key}") // rejected - two spaces ``` -The space earns its place on readability alone: `$"{RedisCommand.SET} {key} {value}"` mirrors how the -command is written everywhere else, and costs nothing — `AppendLiteral` is an empty method. +The space earns its place on readability: `$"{RedisCommand.SET} {key} {value}"` mirrors how the command +is written everywhere else, for ~1.4 ns per space (measured below). **Why reject the rest:** with no literal segments, the compiler-supplied `formattedCount` *is* the argument count, as a compile-time constant — so `*N\r\n` can be written in the constructor with no counting @@ -160,8 +160,20 @@ tight : args=3 literals=0 formattedCount=3 literalLength=0 spaced : args=3 literals=2 formattedCount=3 literalLength=2 ``` -**Nor does it cost anything.** With the check removed, `AppendLiteral` is empty and the call is -eliminated — see the `Separators` benchmark. (It cost 0.45 ns per space while the runtime check existed.) +**It costs about 1.4 ns per space** — less than expected, but not nothing, and notably *not* zero even +with an empty `AppendLiteral`. The `Separators` benchmark renders the same four-argument command spaced +and unspaced: + +``` +Separators_None 62.62 ns 1.00 +Separators_Spaced 66.87 ns 1.07 +``` + +Consistent across both jobs with low deviation, so the `ldstr` and the call are not being fully +eliminated despite the method body being empty. That is ~7% of the render, and a far smaller share of the +operation around it — but "the JIT will nuke it" turned out to be optimistic, and the figure is recorded +rather than assumed. + Both spellings render byte-identically, since the space is discarded, so cache identity (§6.2) is unaffected. From 3b0eee587b3cbc0e33cae580b5a017e9b0ec2a97 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:31:29 +0100 Subject: [PATCH 035/360] Add SER309 and its code fix: literal text in a RESP command Completes the authoring story the design assumed. Literal text between holes is discarded rather than sent, so a command written with inline tokens silently omits them - and the handler's AppendLiteral is deliberately empty, so nothing at runtime will say so. The analyzer is therefore the only guard, and reports it as an error. The code fix is what makes that tolerable rather than merely strict: it rewrites $"{key} nx" to $"{key} {RespLiterals.Nx}", keeping the separators, so you type the command the way it reads and take the fix. It is only offered when a matching declaration already exists in source - declaring one on the caller's behalf would mean choosing a type to put it in, which the fix cannot judge - and it declines a multi-token fragment for a single inline token, or a run of several tokens, neither of which has one answer. Error severity is consistent with the blast-radius principle running through this design rather than an escalation: a discarded literal leaves a well-formed frame missing an argument, so the damage is local, and the rule is what stops it being silent. Compare SER011, where bad fragment bytes desync the connection for every subsequent command and are guarded far harder. 14 new tests (8 analyzer, 6 code fix), 168 in the analyzer suite overall, and a full Release build of Build.csproj passes - which is the check that matters for an error-severity rule. Includes docs/rules/SER309.md and an index entry, matching the existing rules, and records in the design doc the four things that cost time: no records in the netstandard2.0 analyzer project, detection by converted type rather than the newer OperationKind, ToMinimalDisplayString on a property including its type, and the code-fix harness running analyzers but not generators. --- design/interpolated-resp-writer.md | 38 +++- docs/rules/SER309.md | 57 ++++++ docs/rules/index.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 + eng/StackExchange.Redis.Build/Diagnostics.cs | 25 +++ .../RespInterpolationAnalyzer.cs | 75 +++++++ .../RespLiteralCodeFixProvider.cs | 193 ++++++++++++++++++ .../StackExchange.Redis.Build.Tests/SER309.cs | 120 +++++++++++ .../SER309CodeFix.cs | 161 +++++++++++++++ .../InterpolatedWriterUnitTests.cs | 2 + 10 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 docs/rules/SER309.md create mode 100644 eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs create mode 100644 eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs create mode 100644 tests/StackExchange.Redis.Build.Tests/SER309.cs create mode 100644 tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 6c9c964bb..e05a7de96 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -978,8 +978,8 @@ Rules: 5. **No keys inside a `Raw`** — invisible to the handler, so they would never be registered for invalidation or counted for routing. 6. **Shape** — at least one hole; first hole is a `RedisCommand`. -7. **Inline literal tokens** — reject, with a fixer offering `{RespLiterals.Nx}` (or offering to declare - it first). See §2.1. +7. **Inline literal tokens** — reject, with a fixer offering `{RespLiterals.Nx}`. See §2.1. **Implemented** + as `SER309` (`RespInterpolationAnalyzer` + `RespLiteralCodeFixProvider`). 8. Possibly: a better diagnostic than `CS1503` for an unsupported hole type. Rules 1–3 have mechanical code fixes, which is presumably what the CodeFixes assembly is for. @@ -1324,6 +1324,40 @@ Two honest qualifications: --- +### 9.3 The generator, the analyzer and the fixer + +Built, so the authoring story is no longer hand-waved: + +| | | +| --- | --- | +| `RespFragmentGenerator` | implements `[Resp]` partial properties — framing, length prefixes, casing and `ArgCount` by construction | +| `RespInterpolationAnalyzer` | `SER309`: literal text in a RESP command is discarded, not sent. **Error** | +| `RespLiteralCodeFixProvider` | rewrites `$"{key} nx"` to `$"{key} {RespLiterals.Nx}"` | + +The fragment tests now declare only the properties; the generator supplies the bodies, and the exact-frame +assertions pass unchanged — which is the real check, since it means `EX` was inferred and upper-cased, +`lib-name` came through verbatim, and `SETINFO lib-name` counted as two arguments. + +The emitted file suppresses `SER010` and `SER011` at source and nowhere wider, which is the pattern §9.1 +describes: the generator is the sanctioned construction site. + +**The fix is only offered when a declaration already exists.** Declaring one on the caller's behalf would +mean choosing a type to put it in, which the fix cannot judge — so the "declare it and use it" variant +sketched in §2.1 is not implemented. Multi-token fragments are deliberately not offered for a single inline +token either, and neither is a run of several tokens, which has no single answer. + +Notes from building it, in case they bite again: + +- The analyzer project targets `netstandard2.0` against Roslyn 4.3, so: no records (no `IsExternalInit`), + and `LanguageVersion.CSharp11` has to come from the existing `LanguageVersions` shim. +- Detection is by **converted type** on the interpolated string rather than + `OperationKind.InterpolatedStringHandlerCreation`, which keeps it working against that Roslyn floor. +- `ToMinimalDisplayString` on a *property* includes its type, yielding `RespFragment RespLiterals.Nx`; build + the name from the containing type instead. +- The code-fix test harness runs analyzers, not generators, so its sources spell out both halves. + +--- + ## 10. Open questions - **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which diff --git a/docs/rules/SER309.md b/docs/rules/SER309.md new file mode 100644 index 000000000..1037acebb --- /dev/null +++ b/docs/rules/SER309.md @@ -0,0 +1,57 @@ +# SER309: literal text in a RESP command is discarded, not sent + +Only interpolation **holes** become RESP arguments. Literal text between them is thrown away, so a command +written with inline tokens silently omits them. + +```c# +// flagged - "nx" is never sent; the server receives SET key value +ctx.Execute("SET", $"{key} nx {value}"); + +// suggested - declare the token, and use a hole +ctx.Execute("SET", $"{key} {RespLiterals.Nx} {value}"); +``` + +A code fix offers the rewrite whenever a matching declaration exists, so in practice you can type it the way +the command reads and take the fix. + +## Why this is an error + +The command cannot do what it plainly says. `$"{key} nx {value}"` looks like three arguments and sends two, +and nothing at runtime will tell you: the handler's `AppendLiteral` is deliberately empty, so the literal +costs nothing and is simply gone. + +That is a considered trade rather than an oversight. The damage is **local** - the frame is still well-formed, +with one argument missing, so the server errors or does the wrong thing and the connection is unaffected. +Literals never contributed to the `*N` header, so the framing stays correct either way. Compare +[SER011](../exp/SER011.md), where hand-written fragment bytes can desync the connection for every *subsequent* +command, and which is guarded far more aggressively for exactly that reason. + +## A single space is allowed + +One space between holes is permitted and discarded, so a command can read the way it is written everywhere +else: + +```c# +ctx.Execute("SET", $"{key} {value}"); // fine - the space is a separator +ctx.Execute("SET", $"{key}{value}"); // also fine - identical bytes +``` + +Two spaces are **not**, and that is deliberate: they look identical to one on the page, so the only place that +distinction can be caught is here. + +## Declaring a token + +Tokens are declared as partial properties and the body is generated, so framing, length prefixes and casing +are correct by construction: + +```c# +[Resp] private static partial RespFragment Nx { get; } // "NX" +[Resp("SETINFO", "lib-name")] private static partial RespFragment SetInfoLibName { get; } // two arguments +``` + +A token taken from the member name is upper-cased; a token given in the attribute is used verbatim, because +some of what this library sends is lower-case by convention (`yes`, `lib-name`, `replica`, the geo units). + +## Suppressing it + +Don't - fix it instead. If you genuinely want the literal gone, delete it; that is what the code already does. diff --git a/docs/rules/index.md b/docs/rules/index.md index a5f1eb48b..ea59ddff1 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -36,6 +36,7 @@ Unlike everything under [Usage](#usage), these describe code that does not do wh - [SER306](SER306) - waiting for a fire-and-forget result, which is always the default value - [SER307](../SyncOverAsync) - blocking on a redis call instead of awaiting it ("sync over async") - [SER308](../SyncOverAsync) - the same, via the library's own `Wait`/`WaitAll`/`TryWait` helpers +- [SER309](SER309) - **error**: literal text in a RESP command is discarded rather than sent as an argument ## Usage diff --git a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md index a0403f5aa..94d24ba10 100644 --- a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md +++ b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md @@ -15,3 +15,4 @@ SER305 | Usage | Error | QueuedResultAnalyzer: waiting for a command queu SER306 | Usage | Warning | QueuedResultAnalyzer: waiting for a fire-and-forget result reads the default value rather than the server's answer SER307 | Usage | Warning | QueuedResultAnalyzer: blocking on a redis call instead of awaiting it, which ties up a thread-pool thread while the reply needs one of its own SER308 | Usage | Warning | QueuedResultAnalyzer: calling the library's own Wait/WaitAll/TryWait helpers, which block the calling thread +SER309 | Usage | Error | RespInterpolationAnalyzer: literal text in a RESP interpolated command is discarded rather than sent as an argument diff --git a/eng/StackExchange.Redis.Build/Diagnostics.cs b/eng/StackExchange.Redis.Build/Diagnostics.cs index 8a6ebdad7..b62e6b5e7 100644 --- a/eng/StackExchange.Redis.Build/Diagnostics.cs +++ b/eng/StackExchange.Redis.Build/Diagnostics.cs @@ -310,5 +310,30 @@ internal static class Diagnostics isEnabledByDefault: true, helpLinkUri: HelpLink("SER350")); + /// + /// Literal text inside a RESP interpolated command, which is discarded rather than sent. + /// + /// + /// + /// An error, because the code cannot do what it plainly says: $"{key} nx {val}" reads as though + /// nx is an argument, and it is silently dropped. The handler's AppendLiteral is a deliberate + /// no-op, so there is no runtime check to fall back on - by design, because the failure is local (a + /// well-formed frame missing an argument) rather than protocol-damaging. + /// + /// + /// A single space is allowed and discarded, so $"{cmd} {key} {value}" can read the way the command + /// is written everywhere else. Everything else - two spaces, punctuation, a bare command name - is this. + /// + /// + public static readonly DiagnosticDescriptor RespLiteralNotSent = new( + id: "SER309", + title: "Literal text in a RESP command is discarded, not sent", + messageFormat: "Literal text \"{0}\" is discarded rather than sent as an argument; declare it as a [Resp] fragment and use a hole, or delete it", + category: UsageCategory, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Only interpolation holes become RESP arguments; literal text between them is discarded, so a command written with inline tokens silently omits them. A single space is permitted as a separator.", + helpLinkUri: HelpLink("SER309")); + private static string HelpLink(string id) => string.Format(HelpLinkFormat, id); } diff --git a/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs b/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs new file mode 100644 index 000000000..8b06840be --- /dev/null +++ b/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace StackExchange.Redis.Build; + +/// +/// Reports literal text inside a RESP interpolated command, which the handler discards rather than sends. +/// +/// +/// +/// The handler deliberately has no runtime check - AppendLiteral is empty, so the JIT can drop it - +/// which makes this rule the only thing standing between $"{key} nx {val}" and a command that quietly +/// omits nx. Hence error severity: the code cannot do what it plainly says. +/// +/// +/// Detection is by converted type rather than by OperationKind.InterpolatedStringHandlerCreation, which +/// keeps this working against the old Roslyn this assembly compiles against (see RoslynShims). +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RespInterpolationAnalyzer : DiagnosticAnalyzer +{ + private const string HandlerTypeName = "StackExchange.Redis.Interpolated.RespCommandHandler"; + + /// The trimmed token, handed to the code fix so it need not re-parse the literal. + public const string TokenProperty = "Token"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } + = ImmutableArray.Create(Diagnostics.RespLiteralNotSent); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(static start => + { + // the overwhelming majority of compilations have never heard of this library; resolve once and + // do nothing at all when it is absent + var handler = start.Compilation.GetTypeByMetadataName(HandlerTypeName); + if (handler is null) return; + + start.RegisterSyntaxNodeAction( + ctx => Analyze(ctx, handler), + SyntaxKind.InterpolatedStringExpression); + }); + } + + private static void Analyze(SyntaxNodeAnalysisContext context, INamedTypeSymbol handler) + { + var node = (InterpolatedStringExpressionSyntax)context.Node; + var converted = context.SemanticModel.GetTypeInfo(node, context.CancellationToken).ConvertedType; + if (!SymbolEqualityComparer.Default.Equals(converted, handler)) return; + + foreach (var content in node.Contents) + { + if (content is not InterpolatedStringTextSyntax text) continue; + + var value = text.TextToken.ValueText; + if (value == " ") continue; // the one permitted separator + + var token = value.Trim(); + var properties = ImmutableDictionary.Empty.Add(TokenProperty, token); + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.RespLiteralNotSent, + text.GetLocation(), + properties, + value)); + } + } +} diff --git a/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs new file mode 100644 index 000000000..56d892e76 --- /dev/null +++ b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace StackExchange.Redis.CodeFixes; + +/// +/// Fixes SER309 - literal text in a RESP command - by replacing the token with a hole referencing a declared +/// [Resp] fragment: $"{key} nx {val}" becomes $"{key} {RespLiterals.Nx} {val}". +/// +/// +/// +/// This is the point of rejecting inline tokens rather than splitting them at runtime: you type it the way the +/// command reads, take the fix, and the committed code is the strict form. Making literal runs work instead +/// would cost the compile-time argument count on every call site that used them. +/// +/// +/// Only offered when a matching declaration already exists in source. Declaring one on the caller's behalf +/// would mean choosing a type to put it in, which is a judgement this cannot make. +/// +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RespLiteralCodeFixProvider))] +[Shared] +public sealed class RespLiteralCodeFixProvider : CodeFixProvider +{ + private const string LiteralNotSentId = "SER309"; + private const string TokenProperty = "Token"; + private const string RespAttributeName = "StackExchange.Redis.Interpolated.RespAttribute"; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(LiteralNotSentId); + + /// + // No FixAllProvider: each literal resolves to a different member, and a run that resolves to none is left + // alone - "fix all" would imply a uniform answer that does not exist. + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + var model = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (model is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(TokenProperty, out var token) || string.IsNullOrEmpty(token)) continue; + + // a run of several tokens has no single answer; leave it for the human + if (token!.IndexOf(' ') >= 0) continue; + + if (root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) + is not InterpolatedStringTextSyntax text) continue; + + var match = FindFragment(model.Compilation, token!, context.CancellationToken); + if (match is null) continue; + + // built from the containing type rather than ToMinimalDisplayString(property), which includes + // the property's TYPE and would produce "RespFragment RespLiterals.Nx" + var name = match.ContainingType.ToMinimalDisplayString(model, text.SpanStart) + "." + match.Name; + context.RegisterCodeFix( + CodeAction.Create( + title: "Use '" + name + "'", + createChangedDocument: _ => Task.FromResult(Apply(context.Document, root, text, name)), + equivalenceKey: LiteralNotSentId), + diagnostic); + } + } + + /// + /// Replace the literal with (space) (hole) (space), keeping the separators it had - a single space either + /// side, since more than one is itself the diagnostic. + /// + private static Document Apply(Document document, SyntaxNode root, InterpolatedStringTextSyntax text, string name) + { + var raw = text.TextToken.ValueText; + var replacements = new List(); + + if (raw.Length != 0 && char.IsWhiteSpace(raw[0])) replacements.Add(Text(" ")); + + replacements.Add(SyntaxFactory.Interpolation(SyntaxFactory.ParseExpression(name))); + + if (raw.Length > 1 && char.IsWhiteSpace(raw[raw.Length - 1])) replacements.Add(Text(" ")); + + return document.WithSyntaxRoot(root.ReplaceNode(text, replacements)); + } + + private static InterpolatedStringTextSyntax Text(string value) + => SyntaxFactory.InterpolatedStringText( + SyntaxFactory.Token(default, SyntaxKind.InterpolatedStringTextToken, value, value, default)); + + /// + /// Find a [Resp] property in source whose token matches, case-insensitively. + /// + /// + /// Source only - walking every referenced assembly would be far more work for no benefit, since the + /// declarations that matter are the ones the caller can see and edit. + /// + private static IPropertySymbol? FindFragment(Compilation compilation, string token, CancellationToken cancellationToken) + { + var attribute = compilation.GetTypeByMetadataName(RespAttributeName); + if (attribute is null) return null; + + foreach (var type in AllTypes(compilation.Assembly.GlobalNamespace, cancellationToken)) + { + foreach (var member in type.GetMembers()) + { + if (member is not IPropertySymbol property) continue; + + foreach (var data in property.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(data.AttributeClass, attribute)) continue; + + var declared = TokenOf(data, property); + if (declared is not null && string.Equals(declared, token, StringComparison.OrdinalIgnoreCase)) + { + return property; + } + } + } + } + + return null; + } + + /// + /// The single token a declaration emits: the attribute's, or the member name upper-cased. Null when the + /// fragment spans several tokens, which cannot match one inline token. + /// + private static string? TokenOf(AttributeData data, IPropertySymbol property) + { + var tokens = new List(); + foreach (var arg in data.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var element in arg.Values) + { + if (element.Value is string s) tokens.Add(s); + } + } + else if (arg.Value is string s) + { + tokens.Add(s); + } + } + + return tokens.Count switch + { + 0 => property.Name.ToUpperInvariant(), + 1 => tokens[0], + _ => null, + }; + } + + private static IEnumerable AllTypes(INamespaceSymbol ns, CancellationToken cancellationToken) + { + foreach (var member in ns.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (member) + { + case INamespaceSymbol nested: + foreach (var type in AllTypes(nested, cancellationToken)) yield return type; + break; + case INamedTypeSymbol type: + yield return type; + foreach (var nested in AllNested(type, cancellationToken)) yield return nested; + break; + } + } + } + + private static IEnumerable AllNested(INamedTypeSymbol type, CancellationToken cancellationToken) + { + foreach (var nested in type.GetTypeMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return nested; + foreach (var deeper in AllNested(nested, cancellationToken)) yield return deeper; + } + } +} diff --git a/tests/StackExchange.Redis.Build.Tests/SER309.cs b/tests/StackExchange.Redis.Build.Tests/SER309.cs new file mode 100644 index 000000000..d7878d46d --- /dev/null +++ b/tests/StackExchange.Redis.Build.Tests/SER309.cs @@ -0,0 +1,120 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace StackExchange.Redis.Build.Tests; + +/// +/// Literal text in a RESP interpolated command, which the handler discards rather than sends. +/// +/// +/// An error-severity rule, so the negative cases carry the weight: a false positive here is a broken build on +/// working code. The interesting negatives are the single space (deliberately allowed) and an ordinary +/// interpolated string that happens to be nearby, which must not be touched. +/// +public class SER309 : Verifier +{ + private const string Using = """ + #pragma warning disable SER010 + using StackExchange.Redis; + using StackExchange.Redis.Interpolated; + """; + + [Fact] + public Task InlineToken_IsFlagged() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{|#0: nx |}{value}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx ")); + + [Fact] + public Task TwoSpaces_IsFlagged() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key}{|#0: |}{key}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + + [Fact] + public Task LeadingCommandName_IsFlagged() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{|#0:SET |}{key}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments("SET ")); + + [Fact] + public Task EveryLiteralIsReportedSeparately() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{|#0: nx |}{value}{|#1: xx|}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx "), + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(1).WithArguments(" xx")); + + // ---- negatives --------------------------------------------------------------------------------- + + [Fact] + public Task SingleSpace_IsAllowed() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key} {value}"); + } + } + """); + + [Fact] + public Task NoLiterals_IsAllowed() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{value}"); + } + } + """); + + [Fact] + public Task OrdinaryInterpolatedString_IsNotTouched() => VerifyAsync( + Using + """ + class C + { + string M(RedisKey key) => $"the key is {key}, obviously"; + } + """); + + [Fact] + public Task InterpolatedStringForAnotherHandler_IsNotTouched() => VerifyAsync( + Using + """ + using System.Text; + class C + { + void M(StringBuilder sb, RedisKey key) => sb.Append($"key: {key} here"); + } + """); +} diff --git a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs new file mode 100644 index 000000000..ef38b1e11 --- /dev/null +++ b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs @@ -0,0 +1,161 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using StackExchange.Redis.CodeFixes; +using Xunit; + +namespace StackExchange.Redis.Build.Tests; + +/// +/// The fix for SER309: replace an inline token with a hole referencing a declared [Resp] fragment. +/// +/// +/// This is what makes rejecting inline tokens tolerable rather than merely strict - you write the command the +/// way it reads and take the fix, so the committed code is the strict form without anyone having to remember +/// the member name. +/// +public class SER309CodeFix : CodeFixVerifier +{ + // Both halves are spelled out because the code-fix harness runs analyzers, not generators; in real use + // the bodies come from RespFragmentGenerator. The fixer only looks for a [Resp] property, so this is + // faithful to what it actually resolves against. + private const string Declarations = """ + #pragma warning disable SER010, SER011 + using StackExchange.Redis; + using StackExchange.Redis.Interpolated; + + internal static partial class RespLiterals + { + [Resp] + internal static partial RespFragment Nx { get; } + + [Resp("SETINFO", "lib-name")] + internal static partial RespFragment SetInfoLibName { get; } + } + + internal static partial class RespLiterals + { + internal static partial RespFragment Nx => new("$2\r\nNX\r\n"u8); + + internal static partial RespFragment SetInfoLibName => new("$7\r\nSETINFO\r\n$8\r\nlib-name\r\n"u8, 2); + } + """; + + [Fact] + public Task InlineToken_IsReplacedWithTheDeclaredFragment() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key} {value}{|#0: nx|}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key} {value} {RespLiterals.Nx}"); + } + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx")); + + [Fact] + public Task SeparatorsAreKeptOnBothSides() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{|#0: nx |}{value}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key} {RespLiterals.Nx} {value}"); + } + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx ")); + + [Fact] + public Task MatchingIsCaseInsensitive() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key}{|#0: NX|}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key} {RespLiterals.Nx}"); + } + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" NX")); + + // ---- cases with no fix ------------------------------------------------------------------------- + + [Fact] + public Task UndeclaredToken_OffersNothing() => VerifyNoFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key}{|#0: withsave|}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" withsave")); + + [Fact] + public Task MultiTokenFragment_DoesNotMatchOneInlineToken() => VerifyNoFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("CLIENT", $"{key}{|#0: SETINFO|}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" SETINFO")); + + [Fact] + public Task RunOfSeveralTokens_OffersNothing() => VerifyNoFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key}{|#0: nx xx|}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx xx")); +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 241752bf4..88765c651 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -470,6 +470,7 @@ public void SingleSpacesAreAllowedAndDiscarded() Assert.Equal(new[] { "k" }, Keys(spaced)); } +#pragma warning disable SER309 // deliberately exercising the discard path the analyzer exists to prevent [Fact] public void LiteralsAreDiscardedNotRejectedAtRuntime() { @@ -495,4 +496,5 @@ public void ALiteralCommandStillFailsBecauseThereIsNoCommandHole() var ctx = new RespContext(); Assert.Throws(() => ctx.Execute($"SET {(RedisKey)"k"}").Dispose()); } +#pragma warning restore SER309 } From 21d2bd516a823b748b3571a0e70584ae3501ae9c Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:33:08 +0100 Subject: [PATCH 036/360] Add RespFragment.CreateValidated, and reject leading/trailing spaces Two gaps the design named and the code did not have. CreateValidated is the sanctioned route for a fragment assembled at runtime - typically once, at startup, from configuration. It walks the bytes, checks every $len CRLF payload CRLF, and checks the count matches what was declared. Not gated behind SER011, because the check is the point: the cost is irrelevant when it runs once, and it is the difference between a mistake that throws at the call and one that desyncs the connection somewhere unrelated. Eight malformed shapes are covered, each of which would otherwise have corrupted the stream. It also matters as a precondition. Section 9.1 records that making hand-construction impossible via a generator-emitted #error needs the sanctioned alternative to exist FIRST, or the absolute block is the hostile version. That is now true. The #error itself is deliberately still not built: whether to take that step is a judgement about how hostile to be, and is better made deliberately than as a side effect. SER309 now also rejects a leading or trailing space. The design said it should - "exactly one space" is satisfied by a space that separates nothing - and the analyzer allowed it. A single space is permitted only between two holes. 69 writer tests, 170 analyzer tests, and a full Release build of Build.csproj all pass. --- design/interpolated-resp-writer.md | 14 ++++- .../RespInterpolationAnalyzer.cs | 10 ++- .../Interpolated/RespFragment.cs | 61 +++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../StackExchange.Redis.Build.Tests/SER309.cs | 26 ++++++++ .../InterpolatedWriterFragmentTests.cs | 39 ++++++++++++ 6 files changed, 147 insertions(+), 4 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e05a7de96..c0bda4ea6 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1127,7 +1127,7 @@ A working spike. The surface is public but gated behind `SER010`/`SER011` — se | `tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs` | 41 tests | | `src/StackExchange.Redis/Interpolated/RespFragment.cs` | pre-framed token runs + the `[Resp]` marker | | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | -| `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 6 tests; both halves of the partial-property pattern, hand-written | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 16 tests; declarations only - the generator supplies the bodies | Green on net10.0 and net8.0 (58 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. @@ -1333,6 +1333,7 @@ Built, so the authoring story is no longer hand-waved: | `RespFragmentGenerator` | implements `[Resp]` partial properties — framing, length prefixes, casing and `ArgCount` by construction | | `RespInterpolationAnalyzer` | `SER309`: literal text in a RESP command is discarded, not sent. **Error** | | `RespLiteralCodeFixProvider` | rewrites `$"{key} nx"` to `$"{key} {RespLiterals.Nx}"` | +| `RespFragment.CreateValidated` | the sanctioned runtime route: checks framing and the argument count | The fragment tests now declare only the properties; the generator supplies the bodies, and the exact-frame assertions pass unchanged — which is the real check, since it means `EX` was inferred and upper-cased, @@ -1341,6 +1342,17 @@ assertions pass unchanged — which is the real check, since it means `EX` was i The emitted file suppresses `SER010` and `SER011` at source and nowhere wider, which is the pattern §9.1 describes: the generator is the sanctioned construction site. +`CreateValidated` is the precondition §9.1 names for ever making hand-construction impossible: it walks the +bytes, checks every `$len\r\n…\r\n` and that the count matches, and throws otherwise. Not gated, because the +check is the point — the cost is irrelevant when it runs once at startup, and it is the difference between a +mistake that throws at the call and one that desyncs the connection somewhere unrelated. Eight malformed +shapes are covered by tests, each of which would otherwise have corrupted the stream. + +**The `#error` half is deliberately NOT built.** The generator could detect a hand-written +`new RespFragment(...)` and emit `#error`, and §9.1 records how — but that forecloses the escape hatch, so +`CreateValidated` had to exist first. Whether to take the next step is a judgement about how hostile to be, +which is worth making deliberately rather than as a side effect of me being on a roll. + **The fix is only offered when a declaration already exists.** Declaring one on the caller's behalf would mean choosing a type to put it in, which the fix cannot judge — so the "declare it and use it" variant sketched in §2.1 is not implemented. Multi-token fragments are deliberately not offered for a single inline diff --git a/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs b/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs index 8b06840be..7c9f793b9 100644 --- a/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs +++ b/eng/StackExchange.Redis.Build/RespInterpolationAnalyzer.cs @@ -56,12 +56,16 @@ private static void Analyze(SyntaxNodeAnalysisContext context, INamedTypeSymbol var converted = context.SemanticModel.GetTypeInfo(node, context.CancellationToken).ConvertedType; if (!SymbolEqualityComparer.Default.Equals(converted, handler)) return; - foreach (var content in node.Contents) + var contents = node.Contents; + for (var i = 0; i < contents.Count; i++) { - if (content is not InterpolatedStringTextSyntax text) continue; + if (contents[i] is not InterpolatedStringTextSyntax text) continue; var value = text.TextToken.ValueText; - if (value == " ") continue; // the one permitted separator + + // a single space is permitted, but only BETWEEN holes: a leading or trailing one separates + // nothing, and satisfying "exactly one space" is not the same as being a separator + if (value == " " && i > 0 && i < contents.Count - 1) continue; var token = value.Trim(); var properties = ImmutableDictionary.Empty.Add(TokenProperty, token); diff --git a/src/StackExchange.Redis/Interpolated/RespFragment.cs b/src/StackExchange.Redis/Interpolated/RespFragment.cs index ec63ec720..118982746 100644 --- a/src/StackExchange.Redis/Interpolated/RespFragment.cs +++ b/src/StackExchange.Redis/Interpolated/RespFragment.cs @@ -43,6 +43,67 @@ public RespFragment(ReadOnlySpan bytes, int argCount = 1) ArgCount = argCount; } + /// + /// Create a fragment from bytes, checking that they are well-formed RESP and that they contain + /// exactly bulk strings. + /// + /// The candidate bytes. + /// How many bulk strings should contain. + /// The bytes are not well-formed, or the count disagrees. + /// + /// The sanctioned route for a fragment assembled at runtime — typically once, at startup, from + /// configuration. Not gated, because the check is the point; the cost is irrelevant when it runs once, + /// and it is the difference between a mistake that throws here and one that desyncs the connection + /// somewhere unrelated. Prefer a generated [Resp] declaration whenever the tokens are known at + /// compile time, which is almost always. + /// + public static RespFragment CreateValidated(ReadOnlySpan bytes, int argCount = 1) + { + if (argCount < 1) throw new ArgumentOutOfRangeException(nameof(argCount)); + + var found = 0; + var offset = 0; + while (offset < bytes.Length) + { + if (bytes[offset] != (byte)'$') throw Malformed($"expected '$' at offset {offset}"); + + // length digits + var start = ++offset; + long length = 0; + while (offset < bytes.Length && bytes[offset] >= (byte)'0' && bytes[offset] <= (byte)'9') + { + length = (length * 10) + (bytes[offset] - (byte)'0'); + if (length > int.MaxValue) throw Malformed($"length overflow at offset {start}"); + offset++; + } + + if (offset == start) throw Malformed($"missing length at offset {start}"); + if (offset + 1 >= bytes.Length || bytes[offset] != (byte)'\r' || bytes[offset + 1] != (byte)'\n') + { + throw Malformed($"expected CRLF after the length at offset {offset}"); + } + + offset += 2; + if (offset + length + 2 > bytes.Length) throw Malformed($"payload of {length} runs past the end"); + + offset += (int)length; + if (bytes[offset] != (byte)'\r' || bytes[offset + 1] != (byte)'\n') + { + throw Malformed($"expected CRLF after the payload at offset {offset}"); + } + + offset += 2; + found++; + } + + if (found != argCount) throw Malformed($"contains {found} bulk string(s), but {argCount} was declared"); + + return new RespFragment(bytes, argCount); + } + + private static ArgumentException Malformed(string detail) + => new($"Not a well-formed run of RESP bulk strings: {detail}.", "bytes"); + /// The pre-framed bytes, including every $len prefix and trailing CRLF. public ReadOnlySpan Bytes { get; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index c36df3290..1e7099282 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -51,4 +51,5 @@ [SER010]StackExchange.Redis.Interpolated.RespFrame.Slot.get -> int [SER010]StackExchange.Redis.Interpolated.RespFrame.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.TryGetKeys(scoped System.Span target) -> int +[SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Build.Tests/SER309.cs b/tests/StackExchange.Redis.Build.Tests/SER309.cs index d7878d46d..8bc10921b 100644 --- a/tests/StackExchange.Redis.Build.Tests/SER309.cs +++ b/tests/StackExchange.Redis.Build.Tests/SER309.cs @@ -73,6 +73,32 @@ void M(RespContext ctx, RedisKey key, RedisValue value) Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx "), Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(1).WithArguments(" xx")); + [Fact] + public Task LeadingSpace_IsFlagged() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{|#0: |}{key}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + + [Fact] + public Task TrailingSpace_IsFlagged() => VerifyAsync( + Using + """ + class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key}{|#0: |}"); + } + } + """, + Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + // ---- negatives --------------------------------------------------------------------------------- [Fact] diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index cd5ae932c..da6342398 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -177,4 +177,43 @@ public void UnrecognisedStringCommandIsFramedVerbatim() Assert.Equal("*3|$9|FT.SEARCH|$3|idx|$1|*|", Frame(frame)); } + + // ---- the sanctioned runtime route -------------------------------------------------------------- + + [Fact] + public void CreateValidatedAcceptsWellFormedBytes() + { + var fragment = RespFragment.CreateValidated("$2\r\nEX\r\n"u8); + Assert.Equal(1, fragment.ArgCount); + + var two = RespFragment.CreateValidated("$6\r\nMAXLEN\r\n$1\r\n~\r\n"u8, 2); + Assert.Equal(2, two.ArgCount); + } + + [Theory] + // the failure modes that would otherwise desync the connection, each caught here instead + [InlineData("2\r\nEX\r\n", 1)] // no '$' + [InlineData("$\r\nEX\r\n", 1)] // no length + [InlineData("$2EX\r\n", 1)] // no CRLF after the length + [InlineData("$3\r\nEX\r\n", 1)] // length disagrees with the payload + [InlineData("$2\r\nEX", 1)] // truncated + [InlineData("$2\r\nEXXX", 1)] // no CRLF after the payload + [InlineData("$2\r\nEX\r\n$2\r\nNX\r\n", 1)] // two fragments declared as one + [InlineData("$2\r\nEX\r\n", 2)] // one fragment declared as two + public void CreateValidatedRejectsMalformedBytes(string raw, int argCount) + { + var bytes = Encoding.UTF8.GetBytes(raw); + var error = Assert.Throws(() => RespFragment.CreateValidated(bytes, argCount)); + Assert.Contains("well-formed", error.Message); + } + + [Fact] + public void ValidatedFragmentsWriteLikeGeneratedOnes() + { + var ctx = new RespContext(); + using var generated = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX}"); + using var validated = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespFragment.CreateValidated("$2\r\nEX\r\n"u8)}"); + + Assert.True(generated.Span.SequenceEqual(validated.Span)); + } } From e8994412aecb14ba84921b335ffb8aff5a9cf288 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 12 Sep 2026 23:34:25 +0100 Subject: [PATCH 037/360] Add SER351: say when a [Resp] declaration cannot be implemented The generator implements [Resp] partial properties of type RespFragment and skipped anything else in silence - which surfaces only as CS9248 "must have an implementation part" on a declaration that looks perfectly correct, with nothing explaining why nothing was generated. That is the same failure shape SER350 exists to prevent, so it gets the same treatment. Two causes are reported with the reason: the property is not of type RespFragment, or it is not declared partial. Warning severity, in the Build category, because the compiler already fails the build for the partial case - this exists to explain it rather than duplicate it. Verified by compiling deliberately-bad declarations and reading the output, which is weaker than the coverage the SER3xx rules have. Generator diagnostics have no harness in this repo - SER350 never had one either, and the test project references Analyzer.Testing and CodeFix.Testing but not SourceGenerators.Testing. Recorded in the design notes as worth closing if more generator diagnostics arrive, rather than left as an unexplained gap. --- design/interpolated-resp-writer.md | 5 ++ docs/rules/SER351.md | 38 +++++++++++++ docs/rules/index.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 + eng/StackExchange.Redis.Build/Diagnostics.cs | 19 +++++++ .../RespFragmentGenerator.cs | 57 +++++++++++++++---- 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 docs/rules/SER351.md diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index c0bda4ea6..50fc0facf 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1334,6 +1334,7 @@ Built, so the authoring story is no longer hand-waved: | `RespInterpolationAnalyzer` | `SER309`: literal text in a RESP command is discarded, not sent. **Error** | | `RespLiteralCodeFixProvider` | rewrites `$"{key} nx"` to `$"{key} {RespLiterals.Nx}"` | | `RespFragment.CreateValidated` | the sanctioned runtime route: checks framing and the argument count | +| `SER351` | a `[Resp]` declaration the generator cannot implement, rather than skipping it in silence | The fragment tests now declare only the properties; the generator supplies the bodies, and the exact-frame assertions pass unchanged — which is the real check, since it means `EX` was inferred and upper-cased, @@ -1367,6 +1368,10 @@ Notes from building it, in case they bite again: - `ToMinimalDisplayString` on a *property* includes its type, yielding `RespFragment RespLiterals.Nx`; build the name from the containing type instead. - The code-fix test harness runs analyzers, not generators, so its sources spell out both halves. +- **Generator diagnostics have no test harness here.** `SER350` never had one either; the project references + `Analyzer.Testing` and `CodeFix.Testing` but not `SourceGenerators.Testing`. `SER351` was verified by + compiling a deliberately-bad declaration and reading the output, which is weaker than the other rules' + coverage and is worth closing if more generator diagnostics arrive. --- diff --git a/docs/rules/SER351.md b/docs/rules/SER351.md new file mode 100644 index 000000000..cbc2662d0 --- /dev/null +++ b/docs/rules/SER351.md @@ -0,0 +1,38 @@ +# SER351: `[Resp]` declaration cannot be implemented + +The `RespFragment` generator implements declarations of this exact shape: + +```c# +[Resp] private static partial RespFragment Nx { get; } +``` + +Anything else carrying `[Resp]` is skipped. This rule exists so that skipping is *said* rather than silent - +without it, the only symptom is `CS9248: partial property must have an implementation part` on a declaration +that looks perfectly correct, with nothing anywhere explaining why nothing was generated. + +## Causes + +**The property is not of type `RespFragment`.** The generator emits pre-framed RESP bytes; there is nothing +meaningful to emit for another type. + +```c# +[Resp] private static partial int Nx { get; } // SER351 +``` + +**The property is not `partial`.** There is no implementation part for the generator to supply, so the +attribute does nothing at all. + +```c# +[Resp] private static RespFragment Nx => default; // SER351 +``` + +## Fixing it + +Make the declaration `partial` and of type `RespFragment`, or remove the attribute if it was not meant to be +generated. If you need a fragment built at runtime rather than from declared tokens, use +`RespFragment.CreateValidated`, which checks the framing instead. + +## Related + +- [SER309](SER309) - literal text in a RESP command, which is what these declarations are usually for +- [SER011](../exp/SER011.md) - constructing a fragment by hand, which this rule is steering you away from diff --git a/docs/rules/index.md b/docs/rules/index.md index ea59ddff1..b85d0133e 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -49,6 +49,7 @@ Unlike everything under [Usage](#usage), these describe code that does not do wh ## Build - [SER350](SER350) - language version too low for generated code +- [SER351](SER351) - a `[Resp]` declaration the fragment generator cannot implement ## When these rules stay quiet diff --git a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md index 94d24ba10..843154d08 100644 --- a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md +++ b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md @@ -16,3 +16,4 @@ SER306 | Usage | Warning | QueuedResultAnalyzer: waiting for a fire-and-for SER307 | Usage | Warning | QueuedResultAnalyzer: blocking on a redis call instead of awaiting it, which ties up a thread-pool thread while the reply needs one of its own SER308 | Usage | Warning | QueuedResultAnalyzer: calling the library's own Wait/WaitAll/TryWait helpers, which block the calling thread SER309 | Usage | Error | RespInterpolationAnalyzer: literal text in a RESP interpolated command is discarded rather than sent as an argument +SER351 | Build | Warning | RespFragmentGenerator: a [Resp] declaration that cannot be implemented, which would otherwise be skipped silently diff --git a/eng/StackExchange.Redis.Build/Diagnostics.cs b/eng/StackExchange.Redis.Build/Diagnostics.cs index b62e6b5e7..d36011971 100644 --- a/eng/StackExchange.Redis.Build/Diagnostics.cs +++ b/eng/StackExchange.Redis.Build/Diagnostics.cs @@ -335,5 +335,24 @@ internal static class Diagnostics description: "Only interpolation holes become RESP arguments; literal text between them is discarded, so a command written with inline tokens silently omits them. A single space is permitted as a separator.", helpLinkUri: HelpLink("SER309")); + /// + /// A [Resp] declaration the generator cannot implement, and would otherwise skip in silence. + /// + /// + /// Build category, like , and for the same reason: skipping quietly + /// surfaces as CS9248 "must have an implementation part" on a declaration that looks correct, with + /// nothing anywhere saying why. A warning rather than an error because the compiler already fails the + /// build for the partial case - this exists to explain it, not to duplicate it. + /// + public static readonly DiagnosticDescriptor RespFragmentNotGenerated = new( + id: "SER351", + title: "[Resp] declaration cannot be implemented", + messageFormat: "[Resp] on '{0}' is ignored: {1}", + category: BuildCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The RespFragment generator implements [Resp] partial properties of type RespFragment; a declaration it cannot match is skipped, which would otherwise appear only as a missing implementation part.", + helpLinkUri: HelpLink("SER351")); + private static string HelpLink(string id) => string.Format(HelpLinkFormat, id); } diff --git a/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs b/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs index a61f1c18f..a7af85445 100644 --- a/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs +++ b/eng/StackExchange.Redis.Build/RespFragmentGenerator.cs @@ -40,8 +40,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var fragments = context.SyntaxProvider .ForAttributeWithMetadataName( RespAttributeName, - static (node, _) => node is PropertyDeclarationSyntax decl - && decl.Modifiers.Any(SyntaxKind.PartialKeyword), + // deliberately NOT filtered to partial here: a [Resp] that cannot be implemented is + // reported rather than skipped in silence, which would surface only as CS9248 + static (node, _) => node is PropertyDeclarationSyntax, Transform) .Where(static x => x is not null) .Collect(); @@ -60,14 +61,38 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return; } - Emit(ctx, found!); + var usable = ImmutableArray.CreateBuilder(); + foreach (var fragment in found) + { + if (fragment is null) continue; + if (fragment.Problem is { Length: > 0 }) + { + ctx.ReportDiagnostic(Diagnostic.Create( + Diagnostics.RespFragmentNotGenerated, fragment.Location, fragment.Name, fragment.Problem)); + continue; + } + + usable.Add(fragment); + } + + Emit(ctx, usable.ToImmutable()); }); } private static FragmentInfo? Transform(GeneratorAttributeSyntaxContext context, System.Threading.CancellationToken cancellationToken) { if (context.TargetSymbol is not IPropertySymbol property) return null; - if (property.Type.ToDisplayString() != "StackExchange.Redis.Interpolated.RespFragment") return null; + + var location = context.TargetNode.GetLocation(); + if (property.Type.ToDisplayString() != "StackExchange.Redis.Interpolated.RespFragment") + { + return FragmentInfo.Rejected(property.Name, location, $"its type is '{property.Type.Name}', not RespFragment"); + } + + if (context.TargetNode is not PropertyDeclarationSyntax { } decl || !decl.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return FragmentInfo.Rejected(property.Name, location, "it is not declared 'partial', so there is no body to supply"); + } var tokens = ImmutableArray.Empty; foreach (var attribute in context.Attributes) @@ -106,7 +131,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) property.Name, AccessibilityOf(property.DeclaredAccessibility), property.IsStatic, - tokens); + tokens, + location, + null); } private static string DeclarationOf(INamedTypeSymbol type) @@ -130,7 +157,7 @@ private static string DeclarationOf(INamedTypeSymbol type) _ => "private", }; - private static void Emit(SourceProductionContext ctx, ImmutableArray fragments) + private static void Emit(SourceProductionContext ctx, ImmutableArray fragments) { var sb = new StringBuilder("// ").AppendLine() .Append("// ").Append(nameof(RespFragmentGenerator)).AppendLine() @@ -140,9 +167,9 @@ private static void Emit(SourceProductionContext ctx, ImmutableArray f is not null) - .Select(f => f!) - .GroupBy(f => (f.Namespace, Containers: string.Join("+", f.Containers)))) + if (fragments.IsDefaultOrEmpty) return; + + foreach (var group in fragments.GroupBy(f => (f.Namespace, Containers: string.Join("+", f.Containers)))) { var first = group.First(); var depth = 0; @@ -227,8 +254,18 @@ private sealed class FragmentInfo( string name, string accessibility, bool isStatic, - ImmutableArray tokens) + ImmutableArray tokens, + Location? location, + string? problem) { + /// A declaration that cannot be implemented, carrying why. + public static FragmentInfo Rejected(string name, Location location, string problem) + => new(null, ImmutableArray.Empty, name, "private", false, ImmutableArray.Empty, location, problem); + + public Location? Location => location; + + public string? Problem => problem; + public string? Namespace => ns; public ImmutableArray Containers => containers; From 5d44711a8565a230c5a08c9430a608ec4f358e2d Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 07:17:38 +0100 Subject: [PATCH 038/360] Declare-and-use fix, and confirm using static closes the ergonomic gap Two things. The SER309 fix now has a second action: when no matching declaration exists, declare one in the type containing the call site. Not an obviously right home, but the only one that needs no guessing, and trivially movable - so the strict form costs a keystroke rather than a lookup. It adds partial to the host type when missing, and includes the attribute argument only when inference would not reproduce the token, so nx needs none and lib-ver does. Word boundaries can only come from separators, so withsave becomes Withsave rather than WithSave; guessing where words divide needs a dictionary and would be wrong often enough to be worse than leaving it. Separately: using static works on these fragments, which matters more than it sounds. With it, $"{key} {value} {Nx} {Ex} {300}" reads within braces-and-a- capital of the inline form it replaces - a much weaker case for ever supporting inline tokens than section 2.1 assumed when it weighed them. The harness needed a verifier overload: a fix that declares a partial property leaves the fixed code reporting CS9248, because the harness runs analyzers and code fixes but not generators. Recorded that StateInheritanceMode.Explicit is the wrong tool for that - it drops the inherited references too, and the fixed state stops seeing the library at all. 171 analyzer tests, 71 writer tests, full Release build of Build.csproj passes. --- design/interpolated-resp-writer.md | 32 ++++- .../RespLiteralCodeFixProvider.cs | 120 ++++++++++++++++-- .../CodeFixVerifier.cs | 31 +++++ .../SER309CodeFix.cs | 81 +++++++++++- .../InterpolatedWriterUsingStaticTests.cs | 53 ++++++++ 5 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 50fc0facf..46c562032 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1354,10 +1354,29 @@ shapes are covered by tests, each of which would otherwise have corrupted the st `CreateValidated` had to exist first. Whether to take the next step is a judgement about how hostile to be, which is worth making deliberately rather than as a side effect of me being on a roll. -**The fix is only offered when a declaration already exists.** Declaring one on the caller's behalf would -mean choosing a type to put it in, which the fix cannot judge — so the "declare it and use it" variant -sketched in §2.1 is not implemented. Multi-token fragments are deliberately not offered for a single inline -token either, and neither is a run of several tokens, which has no single answer. +**Both fixes exist.** When a matching declaration is in source, use it; when none is, declare it in the type +containing the call site. That is not an obviously right home, but it is the only one that needs no guessing, +and moving it afterwards is trivial — so the strict form costs a keystroke rather than a lookup. The fix adds +`partial` to the host type when it is missing, and includes the attribute argument only when inference would +not reproduce the token (`nx` needs none; `lib-ver` does). + +Word boundaries can only come from separators, so `withsave` becomes `Withsave`, not `WithSave`. Guessing +where words divide would need a dictionary and would be wrong often enough to be worse. + +A run of several tokens is still left alone, having no single answer. + +#### `using static` closes most of the remaining gap + +`using static` imports the fragments, so the declared form reads very close to the inline one it replaces: + +```csharp +using static RespLiterals; +... +ctx.Execute(RedisCommand.SET, $"{key} {value} {Nx} {Ex} {300}"); // vs. "... nx ex 300" +``` + +Verified. The difference is braces and a capital letter — which is a much weaker case for ever supporting +inline tokens than it looked when §2.1 weighed it. Notes from building it, in case they bite again: @@ -1367,7 +1386,10 @@ Notes from building it, in case they bite again: `OperationKind.InterpolatedStringHandlerCreation`, which keeps it working against that Roslyn floor. - `ToMinimalDisplayString` on a *property* includes its type, yielding `RespFragment RespLiterals.Nx`; build the name from the containing type instead. -- The code-fix test harness runs analyzers, not generators, so its sources spell out both halves. +- The code-fix test harness runs analyzers, not generators, so its sources spell out both halves — and a fix + that *declares* a fragment leaves the fixed code legitimately reporting `CS9248`, which needed a verifier + overload carrying fixed-state diagnostics. Note `StateInheritanceMode.Explicit` is the wrong tool there: it + drops the inherited references too, and the fixed state stops seeing the library at all. - **Generator diagnostics have no test harness here.** `SER350` never had one either; the project references `Analyzer.Testing` and `CodeFix.Testing` but not `SourceGenerators.Testing`. `SER351` was verified by compiling a deliberately-bad declaration and reading the output, which is weaker than the other rules' diff --git a/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs index 56d892e76..071677aaa 100644 --- a/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs +++ b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; namespace StackExchange.Redis.CodeFixes; @@ -23,8 +24,9 @@ namespace StackExchange.Redis.CodeFixes; /// would cost the compile-time argument count on every call site that used them. /// /// -/// Only offered when a matching declaration already exists in source. Declaring one on the caller's behalf -/// would mean choosing a type to put it in, which is a judgement this cannot make. +/// Two fixes. When a matching declaration exists, use it. When none does, declare it in the type containing +/// the call site - not an obviously right home, but the only one that needs no guessing, and it is trivially +/// movable afterwards. Together they mean the strict form costs a keystroke rather than a lookup. /// /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RespLiteralCodeFixProvider))] @@ -34,6 +36,7 @@ public sealed class RespLiteralCodeFixProvider : CodeFixProvider private const string LiteralNotSentId = "SER309"; private const string TokenProperty = "Token"; private const string RespAttributeName = "StackExchange.Redis.Interpolated.RespAttribute"; + private const string FragmentTypeName = "StackExchange.Redis.Interpolated.RespFragment"; /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(LiteralNotSentId); @@ -63,16 +66,36 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) is not InterpolatedStringTextSyntax text) continue; var match = FindFragment(model.Compilation, token!, context.CancellationToken); - if (match is null) continue; + if (match is not null) + { + // built from the containing type rather than ToMinimalDisplayString(property), which includes + // the property's TYPE and would produce "RespFragment RespLiterals.Nx" + var name = match.ContainingType.ToMinimalDisplayString(model, text.SpanStart) + "." + match.Name; + context.RegisterCodeFix( + CodeAction.Create( + title: "Use '" + name + "'", + createChangedDocument: _ => Task.FromResult(Apply(context.Document, root, text, name)), + equivalenceKey: LiteralNotSentId + ":use"), + diagnostic); + continue; + } + + // nothing declared: offer to declare it here. The containing type is not an obviously right home, + // but it is the only one that needs no guessing, and moving it later is trivial. + var host = text.FirstAncestorOrSelf(); + if (host is null) continue; - // built from the containing type rather than ToMinimalDisplayString(property), which includes - // the property's TYPE and would produce "RespFragment RespLiterals.Nx" - var name = match.ContainingType.ToMinimalDisplayString(model, text.SpanStart) + "." + match.Name; + var fragmentType = model.Compilation.GetTypeByMetadataName(FragmentTypeName); + if (fragmentType is null) continue; + + var member = MemberNameFor(token!); + var typeName = fragmentType.ToMinimalDisplayString(model, host.SpanStart); context.RegisterCodeFix( CodeAction.Create( - title: "Use '" + name + "'", - createChangedDocument: _ => Task.FromResult(Apply(context.Document, root, text, name)), - equivalenceKey: LiteralNotSentId), + title: "Declare '" + member + "' here and use it", + createChangedDocument: _ => Task.FromResult( + Declare(context.Document, root, text, host, member, token!, typeName)), + equivalenceKey: LiteralNotSentId + ":declare"), diagnostic); } } @@ -82,6 +105,9 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) /// side, since more than one is itself the diagnostic. /// private static Document Apply(Document document, SyntaxNode root, InterpolatedStringTextSyntax text, string name) + => document.WithSyntaxRoot(root.ReplaceNode(text, Replacements(text, name))); + + private static List Replacements(InterpolatedStringTextSyntax text, string name) { var raw = text.TextToken.ValueText; var replacements = new List(); @@ -92,7 +118,81 @@ private static Document Apply(Document document, SyntaxNode root, InterpolatedSt if (raw.Length > 1 && char.IsWhiteSpace(raw[raw.Length - 1])) replacements.Add(Text(" ")); - return document.WithSyntaxRoot(root.ReplaceNode(text, replacements)); + return replacements; + } + + /// + /// Declare the fragment in and point the literal at it. + /// + /// + /// Both edits land in one tree, so the nodes are tracked across the first rewrite rather than re-found by + /// span - which would be wrong the moment the first edit changes any offset. + /// + private static Document Declare( + Document document, + SyntaxNode root, + InterpolatedStringTextSyntax text, + TypeDeclarationSyntax host, + string member, + string token, + string fragmentTypeName) + { + var tracked = root.TrackNodes(text, host); + + var currentText = tracked.GetCurrentNode(text)!; + var afterLiteral = tracked.ReplaceNode(currentText, Replacements(currentText, member)); + + var currentHost = afterLiteral.GetCurrentNode(host)!; + + // the attribute only needs the token when inference would not produce it; inference upper-cases, so + // "nx" needs nothing and "lib-name" does + var attribute = string.Equals(member.ToUpperInvariant(), token.ToUpperInvariant(), StringComparison.Ordinal) + ? "[Resp]" + : "[Resp(\"" + token + "\")]"; + + // attribute on its own line, with a blank line above, matching how these are normally written + var declaration = SyntaxFactory.ParseMemberDeclaration( + attribute + SyntaxFactory.ElasticCarriageReturnLineFeed + + "private static partial " + fragmentTypeName + " " + member + " { get; }")! + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newHost = currentHost.AddMembers(declaration); + + // the generator supplies the body as another part, so the type has to be partial + if (!newHost.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + newHost = newHost.AddModifiers(SyntaxFactory.Token(SyntaxKind.PartialKeyword)); + } + + return document.WithSyntaxRoot(afterLiteral.ReplaceNode(currentHost, newHost)); + } + + /// + /// A token rendered as a member name: lib-name becomes LibName. + /// + /// + /// Word boundaries can only come from separators, so a single run stays a single word - withsave + /// becomes Withsave, not WithSave. Guessing where words divide would need a dictionary, and + /// would be wrong often enough to be worse than this; rename it afterwards if it matters. + /// + private static string MemberNameFor(string token) + { + var sb = new System.Text.StringBuilder(token.Length); + var upper = true; + foreach (var c in token) + { + if (!char.IsLetterOrDigit(c)) + { + upper = true; + continue; + } + + sb.Append(upper ? char.ToUpperInvariant(c) : char.ToLowerInvariant(c)); + upper = false; + } + + return sb.Length == 0 ? "Token" : sb.ToString(); } private static InterpolatedStringTextSyntax Text(string value) diff --git a/tests/StackExchange.Redis.Build.Tests/CodeFixVerifier.cs b/tests/StackExchange.Redis.Build.Tests/CodeFixVerifier.cs index 81ca12138..3b25c5133 100644 --- a/tests/StackExchange.Redis.Build.Tests/CodeFixVerifier.cs +++ b/tests/StackExchange.Redis.Build.Tests/CodeFixVerifier.cs @@ -49,6 +49,37 @@ protected static Task VerifyFixAsync(string source, string fixedSource, int code return test.RunAsync(TestContext.Current.CancellationToken); } + /// + /// As , but where the fixed code is expected to carry diagnostics of its own. + /// + /// + /// Needed when a fix emits a declaration whose body comes from a generator: this harness runs + /// analyzers and code fixes, not generators, so the result legitimately reports "partial property must + /// have an implementation part". Spelling that out beats weakening the fixed source to something that + /// compiles here but is not what the fix actually produces. + /// + protected static Task VerifyFixAsync( + string source, + string fixedSource, + int codeActionIndex, + DiagnosticResult[] expected, + params DiagnosticResult[] afterFix) + { + var test = new CSharpCodeFixTest + { + TestCode = TestSetup.WithPreamble(source), + FixedCode = TestSetup.WithPreamble(fixedSource), + CodeActionIndex = codeActionIndex, + }; + + TestSetup.Configure(test, referenceLibrary: true, minServerVersion: null); + test.ExpectedDiagnostics.AddRange(expected); + // NOT StateInheritanceMode.Explicit: that drops the inherited references too, and the fixed state + // stops being able to see StackExchange.Redis at all + test.FixedState.ExpectedDiagnostics.AddRange(afterFix); + return test.RunAsync(TestContext.Current.CancellationToken); + } + /// /// Verify that no fix is offered for , which still reports /// . diff --git a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs index ef38b1e11..789042617 100644 --- a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs +++ b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; using StackExchange.Redis.CodeFixes; using Xunit; @@ -40,6 +41,13 @@ internal static partial class RespLiterals } """; + /// + /// The body a declared fragment gets from RespFragmentGenerator, which this harness does not run - so a + /// fix that declares one legitimately leaves the partial property unimplemented here. + /// + private static DiagnosticResult MissingGeneratedBody(string property) + => DiagnosticResult.CompilerError("CS9248").WithLocation(1).WithArguments(property); + [Fact] public Task InlineToken_IsReplacedWithTheDeclaredFragment() => VerifyFixAsync( Declarations + """ @@ -118,10 +126,10 @@ void M(RespContext ctx, RedisKey key) // ---- cases with no fix ------------------------------------------------------------------------- [Fact] - public Task UndeclaredToken_OffersNothing() => VerifyNoFixAsync( + public Task UndeclaredToken_IsDeclaredInTheContainingType() => VerifyFixAsync( Declarations + """ - class C + partial class C { void M(RespContext ctx, RedisKey key) { @@ -129,13 +137,57 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" withsave")); + Declarations + """ + + partial class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("GET", $"{key} {Withsave}"); + } + + [Resp] + private static partial RespFragment {|#1:Withsave|} { get; } + } + """, + 0, + [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" withsave")], + MissingGeneratedBody("C.Withsave")); [Fact] - public Task MultiTokenFragment_DoesNotMatchOneInlineToken() => VerifyNoFixAsync( + public Task DeclaringAHyphenatedTokenKeepsItVerbatim() => VerifyFixAsync( Declarations + """ - class C + partial class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("CLIENT", $"{key}{|#0: lib-ver|}"); + } + } + """, + Declarations + """ + + partial class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("CLIENT", $"{key} {LibVer}"); + } + + [Resp("lib-ver")] + private static partial RespFragment {|#1:LibVer|} { get; } + } + """, + 0, + [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" lib-ver")], + MissingGeneratedBody("C.LibVer")); + + [Fact] + public Task MultiTokenFragment_DoesNotMatchOneInlineToken() => VerifyFixAsync( + Declarations + """ + + partial class C { void M(RespContext ctx, RedisKey key) { @@ -143,7 +195,24 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" SETINFO")); + // SetInfoLibName exists but spans two tokens, so it is not a match for this one; the declare fix is + // offered instead, which is the right answer - CLIENT SETINFO alone is a different fragment + Declarations + """ + + partial class C + { + void M(RespContext ctx, RedisKey key) + { + using var frame = ctx.Execute("CLIENT", $"{key} {Setinfo}"); + } + + [Resp] + private static partial RespFragment {|#1:Setinfo|} { get; } + } + """, + 0, + [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" SETINFO")], + MissingGeneratedBody("C.Setinfo")); [Fact] public Task RunOfSeveralTokens_OffersNothing() => VerifyNoFixAsync( diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs new file mode 100644 index 000000000..9548ab3ab --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; +using static StackExchange.Redis.Tests.RespLiterals; + +namespace StackExchange.Redis.Tests; + +/// Fixed tokens, declared once at namespace scope so they can be imported. +internal static partial class RespLiterals +{ + /// The NX option. + [Resp] + internal static partial RespFragment Nx { get; } + + /// The EX option. + [Resp] + internal static partial RespFragment Ex { get; } + + /// The GET subcommand, of CONFIG GET and friends. + [Resp("GET")] + internal static partial RespFragment Get { get; } +} + +/// +/// Whether using static closes the ergonomic gap that made inline literal tokens tempting. +/// +public class InterpolatedWriterUsingStaticTests +{ + private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + [Fact] + public void ImportedFragmentsReadAlmostLikeInlineTokens() + { + // $"{key} {Nx} {value}" against the inline form it replaces, $"{key} nx {value}" + var ctx = new RespContext(); + using var frame = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {Nx} {Ex} {(RedisValue)300}"); + + Assert.Equal("*6|$3|SET|$1|k|$1|v|$2|NX|$2|EX|$3|300|", Frame(frame)); + Assert.Equal(6, frame.ArgCount); + } + + [Fact] + public void ImportedAndQualifiedAreTheSame() + { + var ctx = new RespContext(); + using var imported = ctx.Execute(RedisCommand.CONFIG, $"{Get} {(RedisValue)"maxmemory"}"); + using var qualified = ctx.Execute(RedisCommand.CONFIG, $"{RespLiterals.Get} {(RedisValue)"maxmemory"}"); + + Assert.True(imported.Span.SequenceEqual(qualified.Span)); + Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Frame(imported)); + } +} From a85068826d238b94a91bc7dfd5df6c15ea7efcf6 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 16:35:23 +0100 Subject: [PATCH 039/360] Merge main (#3220) and fix the same class of bug in the interpolated writer Merging #3220 is behaviourally inert here: the interpolated handler owns a pooled array and never touches IBufferWriter.GetSpan, and the MessageWriter helpers it does call - WriteRaw(Span,...) and WriteCrlf(Span,int) - are the span overloads, which #3220 did not change. The underlying CLASS of defect was present, though: compute a length, then write that many bytes without verifying the destination has room. WriteBulk reserved 'payloadLength + HeaderMax + 2' for a $len\r\n{payload}\r\n bulk string. HeaderMax is 12 - documented as "'*' plus up to NINE digits plus CRLF" - but the real need is payloadLength + digits + 5, so from 1,000,000,000 bytes (10 digits) the reservation was one byte short. It hid the same way my first #3220 test harness hid the original: ArrayPool .Shared rounds up to a power of two, so the extra byte lands in slack. Above 2^30 the pool switches to allocating EXACTLY the requested length, and it bites. Verified both ways - silently absorbed at 1,000,000,000, and IndexOutOfRangeException at 1,100,000,000. Two distinct quantities were sharing one constant, which is how the assumption went unnoticed: HeaderMax (the '*N\r\n' prologue) and the bulk-string prefix happened to be the same number. Now separate, and both sized from Format.MaxInt32TextLen rather than from a plausible digit count. That also fixes HeaderMax itself, which was one short of the 13 a 10-digit argument count needs - unreachable via an interpolated string, but wrong for the same reason. Tested as arithmetic rather than by writing bytes: a write-and-check test cannot prove this, since pool slack absorbs an under-reservation. The test asserts the invariant at every digit-count boundary; reverting BulkReservation to the old formula fails exactly the three 10-digit cases. Also documented that the key-mark packing uses offset 0 as its 'no key' sentinel, which is sound only because the prologue is reserved and the command precedes every key - both now stated where HeaderMax is defined. --- .../Interpolated/RespCommandHandler.cs | 44 ++++++++++++++++-- .../InterpolatedWriterCapacityTests.cs | 45 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterCapacityTests.cs diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 29ca745da..392f73aae 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -20,9 +20,23 @@ namespace StackExchange.Redis.Interpolated [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public ref struct RespCommandHandler { - /// '*' plus up to nine digits plus CRLF; reserved at the front so the header can be + /// '*' plus an int32 text form plus CRLF; reserved at the front so the header can be /// back-filled right-aligned once the final argument count is known. - private const int HeaderMax = 12; + /// + /// Sized from the TYPE, not from what callers plausibly pass: _args is an int, so the + /// text form can be 10 digits, and the previous "up to nine digits" reserve of 12 was one short of + /// the 13 that needs. Unreachable via an interpolated string - but see + /// for the same assumption in a place that was very much reachable. + /// + private const int HeaderMax = 3 + Format.MaxInt32TextLen; + + /// '$' plus an int32 text form plus CRLF: the prefix of one bulk string. + /// + /// A DIFFERENT quantity from , which is why it is now a different constant. + /// used to reserve HeaderMax for this, which happened to be the same + /// number and was one byte short once the length reached 10 digits. + /// + private const int MaxBulkPrefix = 3 + Format.MaxInt32TextLen; private readonly RespContext _context; private byte[] _buffer; @@ -275,9 +289,13 @@ private void DemandCommand() } /// Write '$len\r\n' and return the span the payload should be written into. + /// + /// The reservation is , sized for the widest int32 text form rather + /// than for the lengths callers are expected to use. See that method for why. + /// private Span WriteBulk(int payloadLength, out int payloadOffset) { - Ensure(payloadLength + HeaderMax + 2); + Ensure(BulkReservation(payloadLength)); var span = _buffer.AsSpan(_offset); span[0] = (byte)'$'; payloadOffset = MessageWriter.WriteRaw(span, payloadLength, offset: 1); @@ -306,6 +324,13 @@ private void FoldSlot(scoped ReadOnlySpan payload) /// Record that a key starts at this BUFFER-ABSOLUTE offset. Absolute matters: /// right-aligns the header, so the FRAME start moves with the digit count of the argument count. /// + /// + /// Zero is the "no key here" sentinel, in both slots and in RespFrame.HasNoKeys. That is only + /// sound because a key can never START at offset 0: the first bytes are the + /// reserved prologue, and puts the command ahead of any key. Both halves + /// of that are load-bearing - do not let become 0, and do not allow a key + /// before the command, without giving the marks a real "unset" representation. + /// private void MarkKey(int offset) { if ((_keyMarks & RespFrame.OverflowFlag) != 0) @@ -329,6 +354,19 @@ private void MarkKey(int offset) } } + /// + /// Bytes that must be free for a complete $len\r\n{payload}\r\n bulk string. + /// + /// + /// Sized from , not from the digit count of any particular + /// length. The earlier reservation assumed at most nine digits, so from 1,000,000,000 bytes it was + /// one byte short. That is invisible most of the time - ArrayPool<byte>.Shared rounds up + /// to a power of two, so the extra byte lands in slack - but above 2^30 the pool hands back an array + /// of EXACTLY the requested length, and the write goes out of bounds. Verified both ways: masked at + /// 1,000,000,000 and an at 1,100,000,000. + /// + internal static int BulkReservation(int payloadLength) => payloadLength + MaxBulkPrefix + 2; + private void Ensure(int extra) { if (_buffer.Length - _offset >= extra) return; diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterCapacityTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterCapacityTests.cs new file mode 100644 index 000000000..448bebfdc --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterCapacityTests.cs @@ -0,0 +1,45 @@ +using System; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The buffer arithmetic, asserted directly rather than by writing and hoping something notices. +/// +/// +/// Writing a payload and checking the result cannot prove this: the handler rents from +/// ArrayPool<byte>.Shared, which rounds up to a power of two, so an under-reservation lands in +/// slack and the test passes anyway. That is exactly how the original defect hid - it only surfaces above +/// 2^30, where the pool starts handing back arrays of exactly the requested length. So assert the invariant +/// itself, at every digit-count boundary, where it is cheap and cannot be masked. +/// +public class InterpolatedWriterCapacityTests +{ + /// The bytes a $len\r\n{payload}\r\n bulk string actually occupies. + private static long ActualBulkLength(int payloadLength) + => 1 + payloadLength.ToString(System.Globalization.CultureInfo.InvariantCulture).Length + 2 + (long)payloadLength + 2; + + [Theory] + [InlineData(0)] + [InlineData(9)] + [InlineData(10)] + [InlineData(99)] + [InlineData(100)] + [InlineData(999_999_999)] // 9 digits - the old reservation's unstated assumption + [InlineData(1_000_000_000)] // 10 digits - one byte short before the fix, masked by pool slack + [InlineData(1_073_741_825)] // just above 2^30, where the pool stops rounding up and it bites + [InlineData(int.MaxValue - 64)] + public void ReservationCoversTheBytesActuallyWritten(int payloadLength) + { + long reserved = RespCommandHandler.BulkReservation(payloadLength); + Assert.True( + reserved >= ActualBulkLength(payloadLength), + $"reserved {reserved} for a payload of {payloadLength}, which needs {ActualBulkLength(payloadLength)}"); + } + + /// The reservation must not overflow into a negative for a plausible large payload. + [Fact] + public void ReservationDoesNotOverflowForLargePayloads() + => Assert.True(RespCommandHandler.BulkReservation(int.MaxValue - 64) > 0); +} From 0524263a58c91e60c0eeac78655849ec920ca3a9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 16:50:42 +0100 Subject: [PATCH 040/360] Zero-allocation cache keys: RespCacheKey, RespPayload, RespFrame.Detach The rendered frame becomes the client-side-cache key with no copy, no byte[] and no string - design doc section 6.4. Reuses RefCountedBuffer (the primitive behind RespResult) rather than the neuterable-Dispose/TransferOwnership sketch in the design doc. A count gives every holder one rule - whoever retains, releases - instead of making each one reason about whether ownership moved, which is only known after dispatch. Its TryAddRef is already increment-if-non-zero for exactly the reason this needs, and being a MemoryManager means every Span access routes through one liveness check, so use-after-release throws instead of quietly reading someone else's rent. Added RefCountedBuffer.Adopt for taking over an already-rented array - Rent does its own renting, CreateFixed never returns the buffer. Two things the implementation changed: 1. A lookup must not need ownership. Detach allocates a lease - 48 bytes per call, measured - and on a cache HIT the caller never wanted the buffer. So AsLookupKey borrows the frame's array with no lease and no allocation, and Detach is for the miss path. A steady-state hit now allocates zero. 2. The safety property falls out of that split: a borrowed key cannot be retained, so the documented store idiom (retain, then add) cannot express putting a pooled array into a cache and then handing it back to the pool. The section 6.4 hazard is unreachable rather than merely documented. Note MemoryTrackedPool - the other use-after-free-detecting manager - is behind #if TRACK_MEMORY, which is defined nowhere in the repo. 12 tests, including the zero-allocation assertion on the hit path, the allocation contrast that justifies AsLookupKey, use-after-release throwing, a reader surviving eviction mid-read, and 200 rounds of 8 concurrent readers racing an eviction with the count landing at exactly zero every time. --- design/interpolated-resp-writer.md | 32 ++ src/RESPite/Buffers/RefCountedBuffer.cs | 12 + .../Interpolated/RespCacheKey.cs | 130 +++++++++ .../Interpolated/RespFrame.cs | 50 ++++ .../Interpolated/RespPayload.cs | 97 +++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 31 +- .../InterpolatedWriterCacheKeyTests.cs | 273 ++++++++++++++++++ 7 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespCacheKey.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespPayload.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 46c562032..e3944e071 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -909,6 +909,38 @@ Two requirements: a 256-byte array for the entry's life — roughly a third overhead on retained bytes. Minor, and a custom chunk pool with buckets fitted to the real frame distribution would tighten it. +#### Implemented: `RespCacheKey` / `RespPayload` (see `InterpolatedWriterCacheKeyTests`) + +Two corrections to the sketch above, both found by building it. + +**Reference counting, not ownership transfer.** The neuterable-`Dispose`-plus-`TransferOwnership` design +makes every holder reason about whether ownership moved, and the answer is only known after dispatch. A +count gives every holder one rule — *whoever retains, releases*. The primitive already existed: +`RefCountedBuffer` (`src/RESPite/Buffers/RefCountedBuffer.cs`), which backs `RespResult`. It is a +`MemoryManager` specifically so every `Span`/`Memory` access routes through one liveness check, and +its `TryAddRef` is already increment-if-non-zero, with the same rationale this needs: + +> a reservation racing the final release must fail rather than resurrect a buffer that has already gone +> back to the pool + +So the read side is `TryGetValue(key, out payload) && payload.TryRetain()`, then `try`/`finally` with +`Release()` — success means *found* **and** *count incremented from non-zero*; a zero count is a miss, not +an error. (`MemoryTrackedPool` is the same idea but is behind `#if TRACK_MEMORY`, which is defined +nowhere — it is not a live facility.) + +**A lookup must not need ownership.** `Detach()` transfers the frame's buffer into a lease, and that lease +is an object: **48 bytes per call, measured**. On a cache *hit* — the common case — the caller never wanted +the buffer, so that is a per-lookup allocation buying nothing, which is precisely the cost this design +exists to remove. Hence `AsLookupKey()`, which borrows the frame's array with no lease and no allocation; +`Detach()` is for the miss path, where ownership is actually wanted. + +The two are the same struct, distinguished by `IsOwned`, and the safety property falls out: a borrowed key +**cannot be retained**, so the documented store idiom (retain, then add) cannot express "put a pooled array +into the cache and then hand it back to the pool". That is the §6.4 hazard made unreachable rather than +merely documented. + +Measured: a steady-state cache hit — render, probe, retain, read, release — allocates **zero** bytes. + Pinning also **keeps the key offsets valid**: buffer-absolute offsets stay resolvable for the entry's whole lifetime, so keys can be recovered lazily from a cached entry without re-rendering. Copying would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, diff --git a/src/RESPite/Buffers/RefCountedBuffer.cs b/src/RESPite/Buffers/RefCountedBuffer.cs index 0ef3e7b44..b67a67990 100644 --- a/src/RESPite/Buffers/RefCountedBuffer.cs +++ b/src/RESPite/Buffers/RefCountedBuffer.cs @@ -92,6 +92,18 @@ private RefCountedBuffer(object buffer, int length, bool noReturn, MemoryPool public static RefCountedBuffer CreateFixed(byte[] buffer) => new(buffer, buffer.Length, noReturn: true, pool: null); + /// + /// Take over a buffer that the caller already rented from , with a + /// reference count of one. + /// + /// + /// does its own renting, and never returns the buffer at + /// all; this is the case where bytes have already been written into a rented array and ownership is + /// being handed over rather than copied. The caller must not touch the array afterwards - it now + /// belongs to the reference count. + /// + public static RefCountedBuffer Adopt(byte[] buffer, int length) => new(buffer, length, noReturn: false, pool: null); + /// /// The number of live references; for assertions and tests. /// diff --git a/src/StackExchange.Redis/Interpolated/RespCacheKey.cs b/src/StackExchange.Redis/Interpolated/RespCacheKey.cs new file mode 100644 index 000000000..bc5a8430f --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespCacheKey.cs @@ -0,0 +1,130 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using RESPite; +using RESPite.Buffers; +using RESPite.Messages; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A rendered RESP frame, detached from its builder and usable as a dictionary key + /// without ever being copied into a byte[] or a string. + /// + /// + /// + /// This is the client-side-cache half of the interpolated writer: the bytes that were going to be sent + /// anyway ARE the cache key, so a lookup costs a render and no allocation at all. Deliberately not a + /// ref struct - a ref struct cannot be a TKey - which is why the payload lives in a + /// pooled array behind a rather than in a stackalloc. + /// + /// + /// Lifetime. Whoever retains, releases. hands back a key holding + /// one reference; takes another. Dispose each one exactly once. The rule for the + /// dictionary is that the STORED key holds its own reference for as long as it is in the dictionary - + /// see the remarks on - which is what section 6.4 of the design doc means by "the + /// cache entry pins the lease". + /// + /// + /// Why a reference count and not ownership transfer. The design doc originally sketched a + /// neuterable Dispose plus TransferOwnership. A count is less error-prone: with transfer, + /// every holder has to know whether ownership moved, and the answer is only known after dispatch. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespCacheKey : IEquatable, IDisposable + { + private readonly byte[]? _array; + private readonly RefCountedBuffer? _lease; // null => BORROWED: this key owns no reference + private readonly int _offset; + private readonly int _length; + private readonly int _hash; + + internal RespCacheKey(byte[] array, RefCountedBuffer? lease, int offset, int length) + { + _array = array; + _lease = lease; + _offset = offset; + _length = length; + _hash = RedisValue.GetHashCode(array.AsSpan(offset, length)); + } + + /// Whether this key refers to anything; a default instance does not. + public bool IsEmpty => _array is null; + + /// + /// Whether this key owns a reference to its buffer, and so may be stored. + /// + /// + /// False for a key from , which borrows the frame's buffer and is + /// valid only until the frame is disposed. Storing a borrowed key would put a pooled array into a + /// cache and then hand it back to the pool - design doc section 6.4, whose failure mode is wrong data + /// served from cache rather than a crash. refuses, so the documented + /// retain-then-store idiom cannot express the mistake. + /// + public bool IsOwned => _lease is not null; + + /// + /// The rendered frame. Throws once the last reference has gone, rather than quietly reading bytes + /// that now belong to somebody else's rent. + /// + /// + /// The throw comes from , which is a + /// precisely so that every span access routes through one check. It is a misuse detector, not a + /// substitute for holding a reference - see . + /// + // owned: routed through the lease, so use-after-free throws; borrowed: straight at the frame's array + public ReadOnlySpan Span => _lease is not null + ? _lease.GetSpan().Slice(_offset, _length) + : _array is null ? default : _array.AsSpan(_offset, _length); + + /// Read the frame back, for tests and diagnostics. + public RespReader GetReader() => new(Span); + + /// + /// Take another reference and return a key that owns it, for handing to a cache that will outlive + /// the caller's own using. + /// + /// + /// Returns false if the buffer is already dead. Store the key this produces, not the one you + /// called it on: they compare equal and address the same bytes, but they are separate references + /// and each must be disposed once. The usual shape is retain, try to add, and dispose the retained + /// copy if the add lost a race. + /// + public bool TryRetain(out RespCacheKey retained) + { + if (_lease is not null && _lease.TryAddRef()) + { + retained = this; + return true; + } + + retained = default; + return false; // borrowed, or the buffer is already back in the pool + } + + /// Release this key's reference; the buffer returns to the pool with the last one. + /// Release exactly one reference per retain. A default key holds none. + public void Dispose() => _lease?.Release(); + + /// Compare by CONTENT, so a freshly rendered frame finds a cached one. + /// + /// Content equality is the entire point: the lookup key and the stored key are different rentals of + /// different arrays. Canonicality of the rendering is therefore a correctness property - see design + /// doc section 6. + /// + public bool Equals(RespCacheKey other) + => _hash == other._hash && _length == other._length && Span.SequenceEqual(other.Span); + + /// + public override bool Equals(object? obj) => obj is RespCacheKey other && Equals(other); + + /// + /// Computed once, when the key is detached, while the bytes are already in cache. + public override int GetHashCode() => _hash; + + /// + public override string ToString() => + _lease is null ? "(empty)" : System.Text.Encoding.UTF8.GetString(Span.ToArray()).Replace("\r\n", "|"); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 8c14cfed9..84b76967e 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using RESPite; +using RESPite.Buffers; namespace StackExchange.Redis.Interpolated { @@ -84,6 +85,55 @@ private readonly KeyRange PayloadOf(int offset) return new KeyRange(i + 2, length); } + /// + /// Hand the rendered bytes over to a reference-counted lease and return a key that can live in a + /// dictionary. The frame gives up ownership: disposing it afterwards does nothing. + /// + /// + /// + /// This is the point of the whole exercise - the bytes that were about to be sent become the cache + /// key with no copy, no byte[] and no string. The key is a normal struct rather than a + /// ref struct precisely so it can be a TKey. + /// + /// + /// The returned key holds ONE reference. Dispose it when done; if it is being stored, take a second + /// with and store that. + /// + /// + /// Note the same struct-copy caveat as : this clears ownership on THIS copy of + /// the frame, so a copy taken earlier still holds the array reference and must not be disposed. + /// + /// + public RespCacheKey Detach() + { + var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); + _buffer = null; // ownership moves to the lease + return new RespCacheKey(buffer, RefCountedBuffer.Adopt(buffer, buffer.Length), _start, _length); + } + + /// + /// A key that BORROWS this frame's buffer, for probing a cache without taking ownership of anything. + /// Valid only until the frame is disposed. + /// + /// + /// + /// This is the zero-allocation path, and it is the common one: on a cache HIT the caller never wanted + /// the buffer, so paying for a lease to find that out is pure waste. allocates a + /// - small, but one per lookup, which is exactly the kind of per-call + /// cost this whole design exists to remove. Measured: 48 bytes a lookup with , + /// zero with this. + /// + /// + /// The returned key cannot be retained and so cannot be stored; call on a miss, + /// when ownership is actually wanted. + /// + /// + public RespCacheKey AsLookupKey() + { + var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); + return new RespCacheKey(buffer, lease: null, _start, _length); + } + /// Return the underlying buffer to the pool; safe to call more than once. public void Dispose() { diff --git a/src/StackExchange.Redis/Interpolated/RespPayload.cs b/src/StackExchange.Redis/Interpolated/RespPayload.cs new file mode 100644 index 000000000..afbbc0896 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespPayload.cs @@ -0,0 +1,97 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using RESPite; +using RESPite.Buffers; +using RESPite.Messages; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A cached response body, held as a pooled blob rather than as parsed objects, so + /// that a cache hit costs a reference-count bump and a parse - and no allocation. + /// + /// + /// + /// The protocol. A cache entry can be evicted or invalidated at any moment, including between the + /// dictionary lookup and the read. So finding the entry is not enough - the read side must be: + /// + /// + /// if (cache.TryGetValue(key, out var payload) && payload.TryRetain()) + /// { + /// try { /* parse payload.Span here; the buffer cannot be recycled */ } + /// finally { payload.Release(); } + /// } + /// + /// + /// Success means BOTH that the entry was found AND that the reference count was incremented from a + /// non-zero value. A zero count means eviction already won the race and the buffer is back in the pool; + /// that is a miss, not an error. This is the shape used in HybridCache for the same reason. + /// + /// + /// Why the increment must be checked. A bare Interlocked.Increment would resurrect a count + /// from zero - exactly the window in which the array has already been handed back and may already be + /// serving another rent. is increment-if-non-zero for that + /// reason, and the failure mode it prevents is wrong data served from cache, not a crash. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class RespPayload : IDisposable + { + private readonly RefCountedBuffer _lease; + private readonly int _offset; + private readonly int _length; + + internal RespPayload(RefCountedBuffer lease, int offset, int length) + { + _lease = lease; + _offset = offset; + _length = length; + } + + /// Copy a response body into a pooled blob, with one reference held by the caller. + /// The bytes to cache. + /// + /// A copy, because the bytes being cached arrive in a connection buffer that is about to be reused. + /// In the real thing this is where the response frame's own lease would be shared instead - see + /// RespResult, which already reserves against the reader's buffer rather than copying. + /// + public static RespPayload Create(ReadOnlySpan value) + { + var buffer = ArrayPool.Shared.Rent(Math.Max(1, value.Length)); + value.CopyTo(buffer); + return new RespPayload(RefCountedBuffer.Adopt(buffer, buffer.Length), 0, value.Length); + } + + /// The number of live references; zero once the blob is back in the pool. + internal int RefCount => _lease.RefCount; + + /// + /// Take a reference, so the blob cannot be recycled while it is being read. Returns false if + /// it has already gone - treat that as a cache miss. + /// + /// Every successful call must be paired with exactly one . + public bool TryRetain() => _lease.TryAddRef(); + + /// Drop a reference taken by . + public void Release() => _lease.Release(); + + /// + /// The cached bytes. Only valid while a reference is held; throws once the last one has gone. + /// + /// + /// The throw catches a caller reading after releasing. It is NOT a substitute for holding a + /// reference: without one, another thread can recycle the buffer between the check and the read, + /// and then the bytes are simply somebody else's. Correctness comes from . + /// + public ReadOnlySpan Span => _lease.GetSpan().Slice(_offset, _length); + + /// + /// A reader over the cached bytes. A ref struct, so it cannot outlive the retained window. + /// + public RespReader GetReader() => new(Span); + + /// Drop the reference held by whoever created or retained this payload. + public void Dispose() => Release(); + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1e7099282..b39cffbc1 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,14 +1,26 @@ #nullable enable +[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.Equals(object? obj) -> bool +[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.GetHashCode() -> int +[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.ToString() -> string! [SER010]StackExchange.Redis.Interpolated.KeyRange -[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void +[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.Length.get -> int [SER010]StackExchange.Redis.Interpolated.KeyRange.Offset.get -> int [SER010]StackExchange.Redis.Interpolated.RespAttribute -[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void -[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token, params string![]! additionalTokens) -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.Tokens.get -> string![]! +[SER010]StackExchange.Redis.Interpolated.RespCacheKey +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Equals(StackExchange.Redis.Interpolated.RespCacheKey other) -> bool +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.GetReader() -> RESPite.Messages.RespReader +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.IsEmpty.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.IsOwned.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.RespCacheKey() -> void +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespCacheKey.TryRetain(out StackExchange.Redis.Interpolated.RespCacheKey retained) -> bool [SER010]StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void @@ -17,9 +29,9 @@ [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Complete() -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Dispose() -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.CancellationToken.get -> System.Threading.CancellationToken [SER010]StackExchange.Redis.Interpolated.RespContext.ChannelPrefix.get -> StackExchange.Redis.RedisChannel @@ -43,6 +55,8 @@ [SER010]StackExchange.Redis.Interpolated.RespFragment.RespFragment() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespFrame.ArgCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespFrame.AsLookupKey() -> StackExchange.Redis.Interpolated.RespCacheKey +[SER010]StackExchange.Redis.Interpolated.RespFrame.Detach() -> StackExchange.Redis.Interpolated.RespCacheKey [SER010]StackExchange.Redis.Interpolated.RespFrame.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.HasNoKeys.get -> bool @@ -51,5 +65,12 @@ [SER010]StackExchange.Redis.Interpolated.RespFrame.Slot.get -> int [SER010]StackExchange.Redis.Interpolated.RespFrame.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.TryGetKeys(scoped System.Span target) -> int +[SER010]StackExchange.Redis.Interpolated.RespPayload +[SER010]StackExchange.Redis.Interpolated.RespPayload.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespPayload.GetReader() -> RESPite.Messages.RespReader +[SER010]StackExchange.Redis.Interpolated.RespPayload.Release() -> void +[SER010]StackExchange.Redis.Interpolated.RespPayload.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment +[SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs new file mode 100644 index 000000000..7c85f1f4f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Concurrent; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The rendered frame as a client-side-cache key: no copy, no string, and a lease that cannot be recycled +/// while anyone is reading it. See design/interpolated-resp-writer.md section 6.4. +/// +public class InterpolatedWriterCacheKeyTests +{ + private static RespCacheKey Key(string key) + { + var ctx = new RespContext(); + var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + return frame.Detach(); // ownership moves to the key; the frame must not be disposed after this + } + + private static string Text(ReadOnlySpan value) => + Encoding.UTF8.GetString(value.ToArray()).Replace("\r\n", "|"); + + [Fact] + public void DetachedKeyHoldsTheRenderedFrame() + { + using var key = Key("abc"); + Assert.Equal("*2|$3|GET|$3|abc|", Text(key.Span)); + } + + [Fact] + public void SeparateRendersOfTheSameCommandAreEqual() + { + using var a = Key("abc"); + using var b = Key("abc"); + + // different rentals, different arrays - equality has to be by content, or the cache never hits + Assert.False(ReferenceEquals(null, null) && a.Span == b.Span); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DifferentCommandsAreNotEqual() + { + using var a = Key("abc"); + using var b = Key("abd"); + Assert.NotEqual(a, b); + } + + [Fact] + public void WorksAsAConcurrentDictionaryKey() + { + var cache = new ConcurrentDictionary(); + + var stored = Key("abc"); + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + Assert.True(cache.TryAdd(stored, payload)); + + // a completely separate render finds it + using (var lookup = Key("abc")) + { + Assert.True(cache.TryGetValue(lookup, out var found)); + Assert.True(found.TryRetain()); + try + { + Assert.Equal("$5|hello|", Text(found.Span)); + } + finally + { + found.Release(); + } + } + + Assert.True(cache.TryRemove(stored, out _)); + payload.Dispose(); + stored.Dispose(); + } + + [Fact] + public void LookupAllocatesNothingOnAHit() + { + var cache = new ConcurrentDictionary(); + var stored = Key("abc"); + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + cache.TryAdd(stored, payload); + + // warm everything up: JIT, the pool's per-core stacks, the dictionary's buckets + for (var i = 0; i < 200; i++) Probe(cache); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) Probe(cache); + var after = GC.GetAllocatedBytesForCurrentThread(); + + // the render rents from the pool and returns it, the key is a struct, the payload is already + // allocated, and RespReader is a ref struct - so a steady-state hit should allocate nothing + Assert.Equal(0, after - before); + + cache.TryRemove(stored, out _); + payload.Dispose(); + stored.Dispose(); + + static void Probe(ConcurrentDictionary cache) + { + // the HIT path borrows rather than detaching: Detach allocates a RefCountedBuffer per call + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + if (cache.TryGetValue(frame.AsLookupKey(), out var found) && found.TryRetain()) + { + try + { + if (found.Span.Length == 0) throw new InvalidOperationException(); + } + finally + { + found.Release(); + } + } + } + } + + [Fact] + public void ABorrowedKeyCannotBeRetainedAndSoCannotBeStored() + { + var ctx = new RespContext(); + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + + var borrowed = frame.AsLookupKey(); + Assert.False(borrowed.IsOwned); + + // this is the safety property: the documented store idiom is "retain, then add", and a borrowed + // key refuses to retain - so a pooled array cannot reach a cache by following the idiom + Assert.False(borrowed.TryRetain(out _)); + + using var owned = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach(); + Assert.True(owned.IsOwned); + Assert.Equal(borrowed, owned); // same bytes either way + } + + [Fact] + public void DetachAllocatesAndBorrowingDoesNot() + { + var ctx = new RespContext(); + for (var i = 0; i < 200; i++) + { + using var warm = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + warm.AsLookupKey(); + ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + { + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + frame.AsLookupKey(); + } + + var borrowed = GC.GetAllocatedBytesForCurrentThread() - before; + + before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + { + ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(); + } + + var detached = GC.GetAllocatedBytesForCurrentThread() - before; + + // this is why AsLookupKey exists: Detach costs one RefCountedBuffer per call, which on a cache HIT + // buys nothing, because the caller never wanted ownership + Assert.Equal(0, borrowed); + Assert.True(detached > 0, "expected Detach to allocate a lease"); + } + + [Fact] + public void ReadingAfterTheLastReferenceThrows() + { + var key = Key("abc"); + key.Dispose(); + + // the whole point of RefCountedBuffer being a MemoryManager: this throws rather than quietly + // reading bytes that now belong to someone else's rent + Assert.Throws(() => key.Span.Length); + } + + [Fact] + public void RetainKeepsTheBufferAliveAcrossTheOwnersDispose() + { + var key = Key("abc"); + Assert.True(key.TryRetain(out var retained)); + key.Dispose(); // the original holder is done + + Assert.Equal("*2|$3|GET|$3|abc|", Text(retained.Span)); // still valid: the cache pins it + retained.Dispose(); + + Assert.Throws(() => retained.Span.Length); + } + + [Fact] + public void TryRetainFailsOnceTheBufferIsGone() + { + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + Assert.True(payload.TryRetain()); + payload.Release(); + + payload.Dispose(); // the creator's reference; count now zero + + // an evicted entry must report a miss, not resurrect a buffer that is back in the pool + Assert.False(payload.TryRetain()); + } + + [Fact] + public void EvictionDuringUseDoesNotRecycleTheBuffer() + { + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + + Assert.True(payload.TryRetain()); // a reader gets in first + payload.Dispose(); // eviction drops the cache's reference underneath it + + Assert.Equal(1, payload.RefCount); + Assert.Equal("$5|hello|", Text(payload.Span)); // the reader is still safe + + payload.Release(); + Assert.Equal(0, payload.RefCount); + Assert.Throws(() => payload.Span.Length); + } + + [Fact] + public async Task ConcurrentReadersAndOneEvictionNeverTearOrOverRelease() + { + for (var round = 0; round < 200; round++) + { + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + var start = new ManualResetEventSlim(false); + var hits = 0; + + var readers = new Task[8]; + for (var i = 0; i < readers.Length; i++) + { + readers[i] = Task.Run(() => + { + start.Wait(); + if (payload.TryRetain()) + { + try + { + // if eviction could recycle under us, this is where it would show + Assert.Equal("$5|hello|", Text(payload.Span)); + Interlocked.Increment(ref hits); + } + finally + { + payload.Release(); + } + } + }); + } + + var evictor = Task.Run(() => { start.Wait(); payload.Dispose(); }); + + start.Set(); + await Task.WhenAll(readers); + await evictor; + + // whatever the interleaving: every retain that succeeded saw intact bytes, and the count + // lands at exactly zero - no leak, no over-release + Assert.Equal(0, payload.RefCount); + Assert.True(hits >= 0); + } + } +} From d51c362c2e951a87e19986704d5280de8e3b3efe Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 17:22:58 +0100 Subject: [PATCH 041/360] Client-side cache invalidation: two tables, global generation tickets Table 1 (RespClientCache) maps (frame, database) to a payload plus the generations its keys had at send time; table 2 (RespKeyTable) maps key bytes to a generation. A server invalidation touches only table 2 - one hash, one stamp - and never enumerates cache entries, which is what makes BCAST affordable. Verified against the protocol rather than assumed: invalidation carries key names only, no timestamp or version; null means FLUSHALL/FLUSHDB; the server sends false invalidations by design when its bounded tracking table overflows; and tracking ignores the database, so table 1 is keyed with the db and table 2 without. That asymmetry is protocol-faithful and commented, because it reads like an oversight. Design points: - Generations are GLOBAL monotonic tickets, not per-key counters. A per-key counter restarting at zero can collide with a ticket an entry recorded before the key was invalidated, validating an entry whose key did change. - Entries hold the key's node directly, so validating a hit is a dereference and a compare, with no re-hashing on the hot path. The price is an invariant: a node leaving table 2 is stamped invalid first, or entries pointing at it would never learn. Mutation-tested. - TryBeginFill captures generations at SEND time and TryComplete refuses if they moved. This is the race that produces PERMANENT staleness - the server drops the key from its table when it fires and never repeats it. Mutation-tested. - Frames with more than two keys are refused outright: the overflow key marks record nothing usable, and an entry whose keys cannot be named could never be invalidated. Failing closed rather than caching something uninvalidatable. Found and fixed while writing it: growth in RespKeyTable originally took all stripe locks while holding one, which deadlocks against another grower on a different stripe. Growth now happens outside any lock. Buckets are copy-on-write arrays rather than linked nodes, so readers never walk a chain being re-linked and growth can rebuild the bucket array while carrying nodes over by reference - which it must, since entries point at them. Measured: OnInvalidate is ~5-6 ns, zero allocation, flat from 1 to 100,000 cached keys - about 170M/sec on one thread, hit or miss. 14 tests. --- design/interpolated-resp-writer.md | 46 +++ .../Interpolated/RespClientCache.cs | 277 +++++++++++++++++ .../Interpolated/RespKeyTable.cs | 271 ++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 14 + .../ClientCacheBenchmarks.cs | 49 +++ .../RespClientCacheTests.cs | 290 ++++++++++++++++++ 6 files changed, 947 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespClientCache.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespKeyTable.cs create mode 100644 tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs create mode 100644 tests/StackExchange.Redis.Tests/RespClientCacheTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e3944e071..dfb75249c 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -941,6 +941,52 @@ merely documented. Measured: a steady-state cache hit — render, probe, retain, read, release — allocates **zero** bytes. +#### 6.6 Invalidation: two tables, not a cross-index + +Verified against the protocol first: an invalidation message carries **an array of key names and nothing +else** — no timestamp, no version, no epoch. A `null` in its place means `FLUSHALL`/`FLUSHDB`. Two further +properties shape the design more than the missing time does: + +- **False invalidations are normal.** The server's invalidation table is bounded; when it fills it evicts + by *pretending a key was modified*. Over-invalidation is routine traffic, so invalidation must be cheap + and correctness must never depend on it being precise. +- **Tracking ignores the database.** *"There is a single keys namespace, not divided by database numbers"* — + writing `foo` in db 3 invalidates a cached `foo` in db 2. + +**The structure.** Two independent lookups rather than one cross-indexed structure: + +| | key | value | +| --- | --- | --- | +| Table 1 — `RespClientCache` | rendered frame **+ database** | payload + the generations its keys had at send time | +| Table 2 — `RespKeyTable` | Redis key bytes, **no database** | a generation | + +A server invalidation touches *only* table 2: one hash, one stamp. It never enumerates cache entries, which +is the whole point — under `BCAST` we are told about every key touched on the server and almost none are +ours. The database asymmetry above is protocol-faithful and looks like a bug; it is commented as such. + +**Generations are global monotonic tickets, not per-key counters.** This is what makes removal and reuse +safe. A per-key counter restarting at zero can collide with a ticket a cached entry recorded before the key +was invalidated, and that entry would then validate against a key that had in fact changed. + +**Entries hold the key's node directly**, so validating a hit is a dereference and a `long` compare — table +2 is never re-hashed on the hot path. The price is one invariant: *a node that leaves table 2 must be +stamped invalid first*, or entries still pointing at it would never learn. Both that invariant and the +in-flight check below are pinned by mutation-tested cases. + +**The fill race is the reason any of this needs ordering.** An invalidation can land between send and +reply, and the server will not repeat it — it dropped the key from its table when it fired. Caching that +reply leaves *permanently* stale data. `TryBeginFill` captures generations at **send** time and +`TryComplete` refuses if they moved, which is the documented "caching-in-progress placeholder" without a +placeholder. + +Everything fails closed: an unresolvable key, a frame whose keys cannot be enumerated, a generation that +moved — all are misses. In particular a frame with **more than two keys is refused outright**, because the +overflow key marks record nothing usable (§5.2's open seam), and an entry whose keys cannot be named could +never be invalidated. `MGET` with two keys caches; with three it does not. + +Measured (`ClientCacheBenchmarks`): `OnInvalidate` is **~5-6 ns, zero allocation, flat from 1 to 100,000 +cached keys** — about 170M invalidations/sec on one thread, for both hits and misses. + Pinning also **keeps the key offsets valid**: buffer-absolute offsets stay resolvable for the entry's whole lifetime, so keys can be recovered lazily from a cached entry without re-rendering. Copying would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs new file mode 100644 index 000000000..402ab210d --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A client-side cache built as two independent lookups rather than a cross-indexed + /// structure. + /// + /// + /// + /// Table 1 (here) maps (rendered frame, database) to a payload plus the generations its + /// keys had when the request was sent. Table 2 () maps Redis key bytes + /// to a generation. A server invalidation touches only table 2, so it costs one hash and one + /// stamp per key and never enumerates cache entries - which is the whole point, since under broadcasting + /// we are told about every key touched on the server, and almost none of them are ours. + /// + /// + /// A hit is valid when every key it depends on still carries the generation recorded at send time. + /// Entries hold the directly, so validating is a dereference and a + /// compare - table 2 is not re-hashed on the hot path. + /// + /// + /// Note the deliberate asymmetry: table 1 is keyed by frame AND database; table 2 by key bytes + /// alone, with no database. That mirrors the protocol - Redis tracking uses "a single keys namespace, + /// not divided by database numbers", so writing foo in database 3 invalidates a cached + /// foo in database 2. It looks like an oversight and is not. + /// + /// + /// Failure is always closed. A key that cannot be resolved, a frame whose keys cannot be enumerated, an + /// entry whose generation no longer matches: all are treated as misses. The only way to serve stale data + /// would be for an invalidation to leave a live node unstamped, which is why nodes are stamped before + /// they are ever dropped from table 2. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class RespClientCache : IDisposable + { + private readonly ConcurrentDictionary _entries = new(); + private readonly RespKeyTable _keys; + + /// Create a cache. + /// Initial size hint for the tracked-key table. + public RespClientCache(int keyCapacity = 256) => _keys = new RespKeyTable(keyCapacity); + + /// The number of cached responses, including any not yet swept after invalidation. + public int Count => _entries.Count; + + /// The number of distinct keys being tracked. + public int TrackedKeyCount => _keys.Count; + + /// + /// Invalidate one key, as reported by the server. Allocation-free, and cheap when the key is not + /// cached here. + /// + /// The key bytes exactly as the server reported them. + /// true if the key was actually tracked. + /// + /// Takes a span rather than anything owned, because in broadcasting mode this is called for every + /// key touched on the server. The work is: hash the span, one array read, one bucket scan. Nothing + /// is allocated, and a key we do not track costs only that. + /// + public bool OnInvalidate(ReadOnlySpan key) => _keys.Invalidate(key); + + /// + /// Invalidate everything - a null invalidation (FLUSHALL/FLUSHDB), a lost connection, + /// or tracking-redir-broken. + /// + /// + /// Stamps every tracked key rather than walking the cache, so entries fail validation on their next + /// lookup and the memory is reclaimed by . + /// + public void OnFlush() => _keys.InvalidateAll(); + + /// + /// Look for a cached response. On success the payload is returned retained - release it when + /// the parse is done. + /// + public bool TryGet(in RespCacheKey frame, int database, [NotNullWhen(true)] out RespPayload? payload) + { + if (_entries.TryGetValue(new EntryKey(frame, database), out var entry) + && entry.IsValid + && entry.Payload.TryRetain()) + { + // re-check after retaining: an invalidation between the check and the retain would otherwise + // let one stale read through the door it had already closed + if (entry.IsValid) + { + payload = entry.Payload; + return true; + } + + entry.Payload.Release(); + } + + payload = null; + return false; + } + + /// + /// Begin a fill, capturing the generations of the frame's keys. Call this before sending the + /// command, not when the reply arrives. + /// + /// + /// + /// This is what closes the race the Redis docs describe: an invalidation can arrive between the send + /// and the reply, and the server will not tell us again, because it dropped the key from its + /// invalidation table when it fired. Caching that reply would leave permanently stale data. By + /// recording generations at send time, can see that the world moved. + /// + /// + /// Returns false - refusing to cache - when the frame's keys cannot be enumerated. Today that + /// means more than two keys, because the frame's inline key marks hold two and the overflow path + /// records nothing usable. Refusing is the safe answer: a cached entry whose keys we cannot name + /// could never be invalidated. + /// + /// + public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) + { + Span ranges = stackalloc KeyRange[2]; + var count = frame.TryGetKeys(ranges); + if (count < 0) + { + fill = default; + return false; // keys not enumerable => not invalidatable => must not be cached + } + + var deps = count == 0 ? [] : new Dependency[count]; + for (var i = 0; i < count; i++) + { + var node = _keys.GetOrAdd(frame.GetKey(ranges[i]), out var generation); + deps[i] = new Dependency(node, generation); + } + + fill = new RespFill(frame.Detach(), database, deps); + return true; + } + + /// + /// Complete a fill, storing the response only if nothing it depends on was invalidated while the + /// command was in flight. + /// + /// false if the fill was abandoned; the response must not be cached. + public bool TryComplete(in RespFill fill, ReadOnlySpan response) + { + if (fill.Key.IsEmpty) return false; + + if (!Dependency.AllValid(fill.Dependencies)) + { + fill.Key.Dispose(); + return false; + } + + if (!fill.Key.TryRetain(out var stored)) + { + fill.Key.Dispose(); + return false; + } + + var entry = new Entry(RespPayload.Create(response), fill.Dependencies); + if (_entries.TryAdd(new EntryKey(stored, fill.Database), entry)) + { + fill.Key.Dispose(); // the dictionary holds its own reference now + return true; + } + + // somebody else filled the same frame first; theirs is as good as ours + stored.Dispose(); + entry.Payload.Dispose(); + fill.Key.Dispose(); + return false; + } + + /// + /// Drop entries that no longer validate, releasing their payloads and keys. + /// + /// The number of entries removed. + /// + /// Invalidation deliberately does no work beyond stamping a generation, so this is where the memory + /// actually comes back. It is O(entries) and belongs on a timer, not on the invalidation path. + /// + public int Sweep() + { + var removed = 0; + foreach (var pair in _entries) + { + if (pair.Value.IsValid) continue; + if (_entries.TryRemove(pair.Key, out var entry)) + { + entry.Payload.Dispose(); + pair.Key.Frame.Dispose(); + removed++; + } + } + + return removed; + } + + /// Release every cached payload and key. + public void Dispose() + { + foreach (var pair in _entries) + { + if (!_entries.TryRemove(pair.Key, out var entry)) continue; + entry.Payload.Dispose(); + pair.Key.Frame.Dispose(); + } + + _keys.InvalidateAll(); + } + + /// One key a cached entry depends on, and the generation it had when the request was sent. + internal readonly struct Dependency(RespKeyTable.Node node, long generation) + { + private readonly RespKeyTable.Node _node = node; + private readonly long _generation = generation; + + // a dereference and a compare - no hashing, no lookup in table 2 + internal bool IsValid => _node.Generation == _generation; + + internal static bool AllValid(Dependency[] dependencies) + { + foreach (var dependency in dependencies) + { + if (!dependency.IsValid) return false; + } + + return true; + } + } + + private sealed class Entry(RespPayload payload, Dependency[] dependencies) + { + internal RespPayload Payload { get; } = payload; + + internal bool IsValid => Dependency.AllValid(dependencies); + } + + /// The frame AND the database; see the note on database asymmetry in the type remarks. + private readonly struct EntryKey(RespCacheKey frame, int database) : IEquatable + { + internal RespCacheKey Frame { get; } = frame; + + private int Database { get; } = database; + + public bool Equals(EntryKey other) => Database == other.Database && Frame.Equals(other.Frame); + + public override bool Equals(object? obj) => obj is EntryKey other && Equals(other); + + public override int GetHashCode() => (Frame.GetHashCode() * 397) ^ Database; + } + + /// An in-flight fill: the frame that will become the cache key, and what it depends on. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespFill + { + internal RespFill(RespCacheKey key, int database, Dependency[] dependencies) + { + Key = key; + Database = database; + Dependencies = dependencies; + } + + internal RespCacheKey Key { get; } + + internal int Database { get; } + + internal Dependency[] Dependencies { get; } + + /// Abandon the fill without caching anything. + public void Abandon() => Key.Dispose(); + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespKeyTable.cs b/src/StackExchange.Redis/Interpolated/RespKeyTable.cs new file mode 100644 index 000000000..99cf98609 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespKeyTable.cs @@ -0,0 +1,271 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. "Table 2": Redis key bytes to a generation ticket, the only structure a server + /// invalidation touches. + /// + /// + /// + /// Tickets are global and monotonic, not per-key counters. That is what makes removal and reuse + /// safe. A per-key counter restarting at zero could collide with a ticket a cached entry recorded before + /// the key was invalidated, and the entry would validate against a key that had in fact changed. A + /// long incremented globally cannot repeat: at 10^9 a second it lasts about 292 years. + /// + /// + /// Invalidation stamps the node, it does not merely remove it. Cache entries hold the + /// directly, so that validating a cache hit is a pointer dereference and a + /// long compare - no hashing, no second lookup. The cost of that is an invariant: a node that + /// leaves this table must be stamped invalid FIRST, or entries still referencing it would never learn + /// and would serve stale data forever. Everything that removes here goes through + /// on the way out. + /// + /// + /// Lookups are lock-free and allocation-free, because in broadcasting mode this is fed every key + /// touched on the server, and almost none of them will be cached here. A probe is: hash the span, read + /// the bucket array, read one bucket, compare. Buckets are copy-on-write arrays rather than linked + /// nodes, so a reader never walks a chain that a writer is re-linking, and growth can rebuild the bucket + /// array without disturbing nodes that cache entries point at. + /// + /// + internal sealed class RespKeyTable + { + /// A generation that can never be handed out, meaning "this key has been invalidated". + internal const long Invalid = 0; + + private static long _ticket; + + /// The next global generation ticket; never zero, never repeated. + internal static long NextTicket() => Interlocked.Increment(ref _ticket); + + private readonly object[] _locks; + private Node[]?[] _buckets; + private int _count; + + internal RespKeyTable(int capacity = 256) + { + var size = 1; + while (size < capacity) size <<= 1; + _buckets = new Node[]?[size]; + _locks = new object[Math.Min(size, 32)]; + for (var i = 0; i < _locks.Length; i++) _locks[i] = new object(); + } + + /// The number of keys currently tracked. + internal int Count => Volatile.Read(ref _count); + + /// One tracked key and its current generation. + internal sealed class Node + { + private long _generation; + + internal Node(byte[] key, int hash, long generation) + { + Key = key; + Hash = hash; + _generation = generation; + } + + internal byte[] Key { get; } + + internal int Hash { get; } + + /// The current generation, or once the key has been invalidated. + internal long Generation => Volatile.Read(ref _generation); + + /// Mark the key invalidated; every entry that recorded a generation now fails to validate. + internal void Invalidate() => Volatile.Write(ref _generation, Invalid); + + /// + /// The generation to record for a fill starting now, reviving the node with a fresh ticket if it + /// had been invalidated. + /// + /// + /// A fresh ticket cannot revive entries that recorded the old one, because tickets never repeat. + /// Two fills racing here may burn a ticket; the loser simply fails to validate later, which + /// costs a miss and nothing else. + /// + internal long EnsureLive() + { + while (true) + { + var current = Volatile.Read(ref _generation); + if (current != Invalid) return current; + + var ticket = NextTicket(); + if (Interlocked.CompareExchange(ref _generation, ticket, Invalid) == Invalid) return ticket; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int HashOf(ReadOnlySpan key) => RedisValue.GetHashCode(key); + + /// + /// Find the node for a key, without allocating. This is the hot path for invalidation traffic. + /// + internal Node? Find(ReadOnlySpan key) + { + var hash = HashOf(key); + var buckets = Volatile.Read(ref _buckets); + var bucket = Volatile.Read(ref buckets[(hash & int.MaxValue) & (buckets.Length - 1)]); + if (bucket is null) return null; + + foreach (var node in bucket) + { + if (node.Hash == hash && node.Key.AsSpan().SequenceEqual(key)) return node; + } + + return null; + } + + /// + /// Invalidate a key. Allocation-free, and cheap when the key is not tracked - which, under + /// broadcasting, is almost every call. + /// + /// true if the key was tracked, so callers can count how much of the flood mattered. + internal bool Invalidate(ReadOnlySpan key) + { + var node = Find(key); + if (node is null) return false; + node.Invalidate(); + return true; + } + + /// Invalidate everything: a null invalidation (FLUSHALL/FLUSHDB), or a lost connection. + internal void InvalidateAll() + { + AcquireAll(); + try + { + foreach (var bucket in _buckets) + { + if (bucket is null) continue; + foreach (var node in bucket) node.Invalidate(); // stamp before dropping; see the type remarks + } + + _buckets = new Node[]?[_buckets.Length]; + Volatile.Write(ref _count, 0); + } + finally + { + ReleaseAll(); + } + } + + /// + /// Get - creating if needed - the node for a key, and the generation a fill starting now should + /// record against it. + /// + internal Node GetOrAdd(ReadOnlySpan key, out long generation) + { + var existing = Find(key); + if (existing is not null) + { + generation = existing.EnsureLive(); + return existing; + } + + var hash = HashOf(key); + var lockObj = _locks[(hash & int.MaxValue) % _locks.Length]; + Node node; + Node[]?[] witnessed; + bool crowded; + lock (lockObj) + { + // re-check: another thread may have added it while we were outside the lock + var raced = Find(key); + if (raced is not null) + { + generation = raced.EnsureLive(); + return raced; + } + + generation = NextTicket(); + node = new Node(key.ToArray(), hash, generation); + witnessed = _buckets; + crowded = Insert(node); + } + + // NOT inside the stripe lock. Growing takes every lock, and a thread holding one stripe while + // waiting for the rest deadlocks against another doing the same from a different stripe. + if (crowded) Grow(witnessed); + return node; + } + + // caller holds the stripe lock for this node's hash; returns whether the table wants growing + private bool Insert(Node node) + { + var buckets = _buckets; + var index = (node.Hash & int.MaxValue) & (buckets.Length - 1); + var bucket = buckets[index]; + + // copy-on-write: readers keep walking the old array, which never changes under them + Node[] updated; + if (bucket is null) + { + updated = [node]; + } + else + { + updated = new Node[bucket.Length + 1]; + Array.Copy(bucket, updated, bucket.Length); + updated[bucket.Length] = node; + } + + Volatile.Write(ref buckets[index], updated); + return Interlocked.Increment(ref _count) > buckets.Length * 2; + } + + private void Grow(Node[]?[] witnessed) + { + AcquireAll(); + try + { + if (!ReferenceEquals(_buckets, witnessed)) return; + + var grown = new Node[]?[witnessed.Length << 1]; + foreach (var bucket in witnessed) + { + if (bucket is null) continue; + foreach (var node in bucket) + { + var index = (node.Hash & int.MaxValue) & (grown.Length - 1); + var target = grown[index]; + if (target is null) + { + grown[index] = [node]; + } + else + { + var updated = new Node[target.Length + 1]; + Array.Copy(target, updated, target.Length); + updated[target.Length] = node; + grown[index] = updated; + } + } + } + + // nodes are carried over BY REFERENCE: cache entries point at them, so they must survive + Volatile.Write(ref _buckets, grown); + } + finally + { + ReleaseAll(); + } + } + + private void AcquireAll() + { + foreach (var lockObj in _locks) Monitor.Enter(lockObj); + } + + private void ReleaseAll() + { + for (var i = _locks.Length - 1; i >= 0; i--) Monitor.Exit(_locks[i]); + } + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index b39cffbc1..6ebbc5206 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -21,6 +21,20 @@ [SER010]StackExchange.Redis.Interpolated.RespCacheKey.RespCacheKey() -> void [SER010]StackExchange.Redis.Interpolated.RespCacheKey.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespCacheKey.TryRetain(out StackExchange.Redis.Interpolated.RespCacheKey retained) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.Abandon() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Sweep() -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, System.ReadOnlySpan response) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespCacheKey frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool [SER010]StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void diff --git a/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs new file mode 100644 index 000000000..5f2e3e9cf --- /dev/null +++ b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs @@ -0,0 +1,49 @@ +using System; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using StackExchange.Redis.Interpolated; + +namespace StackExchange.Redis.Benchmarks; + +// The invalidation path, which under CLIENT TRACKING BCAST is fed EVERY key touched on the server - +// almost none of which this client has cached. So the number that matters is Invalidate_Miss. +[Config(typeof(CustomConfig))] +[MemoryDiagnoser] +public class ClientCacheBenchmarks +{ + private readonly RespClientCache _cache = new(); + private byte[] _hit = null!; + private byte[] _miss = null!; + private byte[] _lookupFrame = null!; + + [Params(1, 1000, 100_000)] + public int CachedKeys { get; set; } + + [GlobalSetup] + public void Setup() + { + var ctx = new RespContext(); + for (var i = 0; i < CachedKeys; i++) + { + var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)("key:" + i)}"); + if (_cache.TryBeginFill(ref frame, 0, out var fill)) + { + _cache.TryComplete(fill, Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + } + } + + _hit = Encoding.UTF8.GetBytes("key:0"); + _miss = Encoding.UTF8.GetBytes("some:key:this:client:never:read"); + _lookupFrame = Encoding.UTF8.GetBytes("unused"); + _ = _lookupFrame; + } + + /// The broadcasting flood: a key we do not have. This is the common case by a wide margin. + [Benchmark(Baseline = true)] + public bool Invalidate_Miss() => _cache.OnInvalidate(_miss); + + /// A key we do have - one hash, one bucket read, one store. + [Benchmark] + public bool Invalidate_Hit() => _cache.OnInvalidate(_hit); +} diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs new file mode 100644 index 000000000..fef73c04d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -0,0 +1,290 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The two-table client-side cache: (frame, db) => (payload, generations), and key => generation. No server +/// involved - invalidation is poked in from outside, exactly as a push handler would. +/// +public class RespClientCacheTests +{ + private static readonly RespContext Ctx = new(); + + private static RespFrame Get(string key) => Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + + private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); + + private static string Text(ReadOnlySpan value) => + Encoding.UTF8.GetString(value.ToArray()).Replace("\r\n", "|"); + + /// Render, fill, and cache - the normal miss-then-populate path. + private static void Fill(RespClientCache cache, string key, string response, int database = 0) + { + var frame = Get(key); + Assert.True(cache.TryBeginFill(ref frame, database, out var fill)); + Assert.True(cache.TryComplete(fill, Utf8(response))); + } + + private static bool TryRead(RespClientCache cache, string key, out string text, int database = 0) + { + using var frame = Get(key); + if (cache.TryGet(frame.AsLookupKey(), database, out var payload)) + { + try + { + text = Text(payload.Span); + return true; + } + finally + { + payload.Release(); + } + } + + text = ""; + return false; + } + + [Fact] + public void FillThenHit() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$5\r\nhello\r\n"); + + Assert.True(TryRead(cache, "abc", out var text)); + Assert.Equal("$5|hello|", text); + Assert.False(TryRead(cache, "other", out _)); + } + + [Fact] + public void InvalidateEvictsLogically() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$5\r\nhello\r\n"); + Assert.True(TryRead(cache, "abc", out _)); + + Assert.True(cache.OnInvalidate(Utf8("abc"))); + Assert.False(TryRead(cache, "abc", out _)); + + // the entry is still resident until a sweep - invalidation deliberately does no more than stamp + Assert.Equal(1, cache.Count); + Assert.Equal(1, cache.Sweep()); + Assert.Equal(0, cache.Count); + } + + [Fact] + public void InvalidatingAnUncachedKeyIsCheapAndReportsFalse() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$5\r\nhello\r\n"); + + Assert.False(cache.OnInvalidate(Utf8("not-cached"))); + Assert.True(TryRead(cache, "abc", out _)); // untouched + } + + [Fact] + public void InvalidationIsAllocationFree() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$5\r\nhello\r\n"); + + var hit = Utf8("abc"); + var miss = Utf8("some:other:key:that:is:not:here"); + for (var i = 0; i < 500; i++) + { + cache.OnInvalidate(hit); + cache.OnInvalidate(miss); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 10_000; i++) + { + cache.OnInvalidate(miss); // the broadcasting flood: keys we do not have + } + + // BCAST hands us every key touched on the server; this path must not allocate at all + Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); + } + + [Fact] + public void ReviveAfterInvalidationDoesNotResurrectTheOldEntry() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$3\r\nold\r\n"); + cache.OnInvalidate(Utf8("abc")); + cache.Sweep(); + + Fill(cache, "abc", "$3\r\nnew\r\n"); + Assert.True(TryRead(cache, "abc", out var text)); + + // the refilled entry must be the NEW one: this is why generations are global tickets rather than + // per-key counters, which would restart and collide with what the old entry recorded + Assert.Equal("$3|new|", text); + } + + [Fact] + public void InvalidationCrossesDatabases() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$2\r\nd0\r\n", database: 0); + Fill(cache, "abc", "$2\r\nd7\r\n", database: 7); + + Assert.True(TryRead(cache, "abc", out _, database: 0)); + Assert.True(TryRead(cache, "abc", out _, database: 7)); + + cache.OnInvalidate(Utf8("abc")); + + // Redis tracking uses one keyspace regardless of database, so both must go + Assert.False(TryRead(cache, "abc", out _, database: 0)); + Assert.False(TryRead(cache, "abc", out _, database: 7)); + } + + [Fact] + public void DifferentDatabasesAreSeparateEntries() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$2\r\nd0\r\n", database: 0); + + Assert.True(TryRead(cache, "abc", out var zero, database: 0)); + Assert.Equal("$2|d0|", zero); + Assert.False(TryRead(cache, "abc", out _, database: 7)); // not the same entry + } + + [Fact] + public void FlushDropsEverything() + { + using var cache = new RespClientCache(); + Fill(cache, "a", "$1\r\na\r\n"); + Fill(cache, "b", "$1\r\nb\r\n"); + + cache.OnFlush(); // null invalidation, or a lost connection + + Assert.False(TryRead(cache, "a", out _)); + Assert.False(TryRead(cache, "b", out _)); + Assert.Equal(2, cache.Sweep()); + } + + /// + /// The race that matters: an invalidation lands while the command is in flight. Caching the reply would + /// leave PERMANENTLY stale data, because the server dropped the key from its invalidation table when it + /// fired and will not tell us again. + /// + [Fact] + public void InvalidationDuringFlightRefusesTheFill() + { + using var cache = new RespClientCache(); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); // generations captured at SEND time + + cache.OnInvalidate(Utf8("abc")); // ... someone writes the key while we wait for the reply ... + + Assert.False(cache.TryComplete(fill, Utf8("$5\r\nstale\r\n"))); + Assert.Equal(0, cache.Count); + Assert.False(TryRead(cache, "abc", out _)); + } + + [Fact] + public void InvalidationBeforeTheFillStartsDoesNotBlockIt() + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$3\r\nold\r\n"); + cache.OnInvalidate(Utf8("abc")); + cache.Sweep(); + + // the invalidation preceded this request, so its reply reflects the write and is cacheable + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(cache.TryComplete(fill, Utf8("$5\r\nfresh\r\n"))); + Assert.True(TryRead(cache, "abc", out var text)); + Assert.Equal("$5|fresh|", text); + } + + [Fact] + public void FramesWhoseKeysCannotBeEnumeratedAreNotCached() + { + using var cache = new RespClientCache(); + + // three keys exceeds the two inline marks, and the overflow path records nothing usable - so the + // keys cannot be named, so the entry could never be invalidated. Refusing is the safe answer. + var frame = Ctx.Execute($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + Assert.True(frame.KeysNeedScan); + Assert.False(cache.TryBeginFill(ref frame, 0, out _)); + frame.Dispose(); + + Assert.Equal(0, cache.Count); + } + + [Fact] + public void MultiKeyEntryIsInvalidatedByAnyOfItsKeys() + { + using var cache = new RespClientCache(); + + var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(cache.TryComplete(fill, Utf8("*2\r\n$1\r\n1\r\n$1\r\n2\r\n"))); + + using (var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) + { + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); + payload.Release(); + } + + cache.OnInvalidate(Utf8("b")); // the SECOND key + + using (var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) + { + Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _)); + } + } + + [Fact] + public void KeyTableGrowsWithoutLosingTrackedKeys() + { + using var cache = new RespClientCache(keyCapacity: 4); + for (var i = 0; i < 400; i++) Fill(cache, "key:" + i, "$1\r\nx\r\n"); + + Assert.Equal(400, cache.Count); + Assert.Equal(400, cache.TrackedKeyCount); + + // growth carries nodes over BY REFERENCE; if it had rebuilt them, every entry's dependency would + // point at an orphan and invalidation would silently stop working + for (var i = 0; i < 400; i++) Assert.True(cache.OnInvalidate(Utf8("key:" + i))); + for (var i = 0; i < 400; i++) Assert.False(TryRead(cache, "key:" + i, out _)); + } + + [Fact] + public async Task ConcurrentInvalidationAndReadsNeverServeStale() + { + for (var round = 0; round < 100; round++) + { + using var cache = new RespClientCache(); + Fill(cache, "abc", "$5\r\nhello\r\n"); + + var start = new ManualResetEventSlim(false); + var readers = new Task[6]; + for (var i = 0; i < readers.Length; i++) + { + readers[i] = Task.Run(() => + { + start.Wait(); + // whatever we get must be intact; after the invalidation it must simply be a miss + if (TryRead(cache, "abc", out var text)) Assert.Equal("$5|hello|", text); + }); + } + + var invalidator = Task.Run(() => { start.Wait(); cache.OnInvalidate(Utf8("abc")); }); + + start.Set(); + await Task.WhenAll(readers); + await invalidator; + + Assert.False(TryRead(cache, "abc", out _)); // settled state: gone + } + } +} From ebcead04ae82796db53b0a376f3bab22f8cf6067 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 18:21:37 +0100 Subject: [PATCH 042/360] Close the key-marks seam: keys past the first two are recoverable The >2-key limit was a budget decision, not an inherent one, and the design doc's framing of it was too pessimistic: 'there aren't spare bits to carry both' is true of the FRAME, which must stay 64 bits, but not of the WRITER. RespCommandHandler is a ref struct on the stack with no size pressure, so it now maintains both representations as it writes - the two offsets and a full argument-index bitmap - and Complete() publishes whichever fits. Nothing is re-derived because nothing is discarded. Previously the third key overwrote the two offsets with a bare OverflowFlag, so keys 1-3 were recorded in neither form and the frame could report nothing; the bits set for keys 4+ were never read by anything. 0 zero 1-2 two 31-bit byte offsets - O(1), no scan 3+ OverflowFlag | bitmap - walk the frame, index -> range The walk is length-prefixed skipping over the rendered bulk strings: no RespReader, no allocation. For the cache it is off the hot path entirely, since dependencies are materialised once per fill, never per lookup. The limitation that remains IS inherent to 64 bits: bit 63 is the mode flag and argument 0 is the command, leaving bits 1-62, so a key at argument index above 62 cannot be recorded. That sets bit 0 as 'truncated' and KeyCount/TryGetKeys report -1 - deliberately not a partial list, since a caller tracking keys for invalidation would believe a partial list complete and cache something it could never invalidate. Recorded in the type docs and design doc, with the rejected alternatives (heap-allocating per frame; treating every argument as a key). Caught while doing this: ThreeKeysFallBackToScanning still passed, but only because the test helper passed a fixed two-element buffer, so TryGetKeys returned -1 for 'target too small' rather than 'cannot report'. The helper now sizes from KeyCount so the two cases cannot be confused, and the test asserts the keys it can now recover. 20 cache tests, 113 interpolated-writer tests, full suite 6300 passing. --- design/interpolated-resp-writer.md | 45 ++++++- .../Interpolated/RespClientCache.cs | 20 ++- .../Interpolated/RespCommandHandler.cs | 67 ++++++---- .../Interpolated/RespFrame.cs | 126 ++++++++++++++++-- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../InterpolatedWriterUnitTests.cs | 20 ++- .../RespClientCacheTests.cs | 90 ++++++++++++- 7 files changed, 306 insertions(+), 63 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index dfb75249c..b0695d1cc 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -687,10 +687,40 @@ PASS zero keys keys(no scan)=[] the real types on newer TFMs. A public method taking `Span` then fails with `CS0051: Inconsistent accessibility`. Use a purpose-built `(offset, length)` struct. -**Open seam:** on promotion to bitmap mode the two stored *offsets* must become *arg indices*, which -were never recorded, and there aren't spare bits to carry both (1 + 31 + 31 leaves one). Pragmatic -answer: re-derive by walking what's already written — rare, partial, and in L1. Needs a deliberate -decision. +**~~Open seam~~ — resolved.** The framing above ("there aren't spare bits to carry both") is true of the +*frame*, which must stay 64 bits. It is not true of the *writer*: `RespCommandHandler` is a `ref struct` on +the stack with no size pressure, so it maintains **both** representations as it writes — the two offsets +and a full argument-index bitmap — and `Complete()` publishes whichever fits. Nothing needs re-deriving, +because nothing is discarded any more. + +The old code overwrote the two offsets with a bare `OverflowFlag` on the third key, so keys 1–3 were +recorded in *neither* form and the frame could report nothing at all; the bits it then set for keys 4+ were +never read by anything. `TryGetKeys` returning −1 was the only honest answer available to it. + +Now: + +| Keys | Encoding | Recovery | +| --- | --- | --- | +| 0 | zero | — | +| 1–2 | two 31-bit byte offsets | O(1), no scan | +| 3+ | `OverflowFlag` \| bitmap of argument indices | walk the frame, mapping index → range | + +The walk is length-prefixed skipping over `*N\r\n` + N bulk strings — no `RespReader`, no allocation. + +**The limitation that remains, and is inherent to a 64-bit field:** bit 63 is the mode flag and argument 0 +is always the command, leaving bits 1–62, so **a key at argument index above 62 cannot be recorded**. That +case sets bit 0 as a "truncated" marker and `KeyCount`/`TryGetKeys` report **−1** — deliberately *not* a +partial list, because a caller tracking keys for invalidation would believe a partial list was complete and +would cache something it could never invalidate. `RespClientCache.TryBeginFill` declines such frames. + +Going beyond 62 would mean heap-allocating the key list per frame, which costs an allocation on every +multi-key command to serve a case that is rare and already enormous. Not worth it unless something real +turns up. + +**Rejected:** falling back to "treat every argument as a key". Over-invalidation is safe by protocol — the +server does it deliberately when its tracking table overflows — but this would register *value* bytes as +tracked keys, polluting the key table and inviting spurious invalidation from unrelated keys that happen to +match a value. Safe, but it degrades the cache in a way that is hard to observe. --- @@ -980,9 +1010,10 @@ reply leaves *permanently* stale data. `TryBeginFill` captures generations at ** placeholder. Everything fails closed: an unresolvable key, a frame whose keys cannot be enumerated, a generation that -moved — all are misses. In particular a frame with **more than two keys is refused outright**, because the -overflow key marks record nothing usable (§5.2's open seam), and an entry whose keys cannot be named could -never be invalidated. `MGET` with two keys caches; with three it does not. +moved — all are misses. In particular a frame whose keys cannot be named is **refused outright**, since an entry that cannot be +invalidated must not be cached. Since §5.2's seam was closed that means only one thing: a key at argument +index above 62. `MGET` over three keys caches and invalidates on any of them; `MGET` over seventy does not +cache at all. Measured (`ClientCacheBenchmarks`): `OnInvalidate` is **~5-6 ns, zero allocation, flat from 1 to 100,000 cached keys** — about 170M invalidations/sec on one thread, for both hits and misses. diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 402ab210d..34e47ceb0 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -112,20 +112,28 @@ public bool TryGet(in RespCacheKey frame, int database, [NotNullWhen(true)] out /// recording generations at send time, can see that the world moved. /// /// - /// Returns false - refusing to cache - when the frame's keys cannot be enumerated. Today that - /// means more than two keys, because the frame's inline key marks hold two and the overflow path - /// records nothing usable. Refusing is the safe answer: a cached entry whose keys we cannot name - /// could never be invalidated. + /// Returns false - refusing to cache - when the frame cannot report its keys. That is now only + /// the case for a key at argument index above 62, which the frame's bitmap has no bit for. Refusing + /// is the safe answer: an entry whose keys cannot be named could never be invalidated. /// /// public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) { - Span ranges = stackalloc KeyRange[2]; + var keyCount = frame.KeyCount; + if (keyCount < 0) + { + fill = default; + return false; // keys not enumerable => not invalidatable => must not be cached + } + + // the overwhelming majority of commands are well under this; only a huge multi-key command + // heaps, and that one already paid for a round trip + Span ranges = keyCount <= 16 ? stackalloc KeyRange[16] : new KeyRange[keyCount]; var count = frame.TryGetKeys(ranges); if (count < 0) { fill = default; - return false; // keys not enumerable => not invalidatable => must not be cached + return false; } var deps = count == 0 ? [] : new Dependency[count]; diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 392f73aae..a5915abd1 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -44,7 +44,10 @@ public ref struct RespCommandHandler private int _args; // RESP argument count, including the command private int _argIndex; // logical argument position, for the overflow bitmap private int _slot; - private ulong _keyMarks; + private ulong _keyBitmap; // argument indices that are keys; bit 0 => a key too far out to record + private int _keyCount; + private int _keyOffsetA; // buffer-absolute offsets of the first two keys + private int _keyOffsetB; private bool _hasCommand; /// Initialize with the command supplied as the first hole. @@ -59,7 +62,6 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _args = 0; _argIndex = 0; _slot = ServerSelectionStrategy.NoSlot; - _keyMarks = 0; _hasCommand = false; } @@ -83,7 +85,6 @@ internal RespCommandHandler(int literalLength, int formattedCount, RespContext c _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + resp.Length + literalLength + (formattedCount * 24)); _offset = HeaderMax; _slot = ServerSelectionStrategy.NoSlot; - _keyMarks = 0; resp.CopyTo(_buffer.AsSpan(_offset)); _offset += resp.Length; @@ -115,7 +116,6 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + resp.Length + nameBytes + literalLength + (formattedCount * 24)); _offset = HeaderMax; _slot = ServerSelectionStrategy.NoSlot; - _keyMarks = 0; _hasCommand = true; _args = 1; _argIndex = 1; @@ -270,7 +270,7 @@ public RespFrame Complete() var start = HeaderMax - headerLength; header.Slice(0, headerLength).CopyTo(_buffer.AsSpan(start)); - var frame = new RespFrame(_buffer, start, _offset - start, _args, _slot, _keyMarks); + var frame = new RespFrame(_buffer, start, _offset - start, _args, _slot, PackKeyMarks()); _buffer = null!; // ownership transferred to the frame return frame; } @@ -325,33 +325,48 @@ private void FoldSlot(scoped ReadOnlySpan payload) /// right-aligns the header, so the FRAME start moves with the digit count of the argument count. /// /// - /// Zero is the "no key here" sentinel, in both slots and in RespFrame.HasNoKeys. That is only - /// sound because a key can never START at offset 0: the first bytes are the - /// reserved prologue, and puts the command ahead of any key. Both halves - /// of that are load-bearing - do not let become 0, and do not allow a key - /// before the command, without giving the marks a real "unset" representation. + /// + /// BOTH representations are maintained as we write, and publishes whichever + /// fits. That costs a few bytes in this handler - a ref struct on the stack, where there is no + /// size pressure - and it is what removes the old promotion problem: the previous code overwrote the + /// two stored offsets with a bare overflow flag on the third key, so the first three keys were + /// recorded in neither form and the frame could report nothing at all. + /// + /// + /// Zero is the "no key here" sentinel for the offset form, in both slots and in + /// RespFrame.HasNoKeys. That is only sound because a key can never START at offset 0: the + /// first bytes are the reserved prologue, and + /// puts the command ahead of any key. Both halves are load-bearing - do not let + /// become 0, and do not allow a key before the command, without giving the + /// marks a real "unset" representation. + /// /// private void MarkKey(int offset) { - if ((_keyMarks & RespFrame.OverflowFlag) != 0) - { - if (_argIndex < 63) _keyMarks |= 1UL << _argIndex; - return; - } + _keyCount++; + if (_keyCount == 1) _keyOffsetA = offset; + else if (_keyCount == 2) _keyOffsetB = offset; - if ((_keyMarks & RespFrame.SlotMask) == 0) - { - _keyMarks |= (ulong)offset & RespFrame.SlotMask; - } - else if (((_keyMarks >> RespFrame.SlotBits) & RespFrame.SlotMask) == 0) - { - _keyMarks |= ((ulong)offset & RespFrame.SlotMask) << RespFrame.SlotBits; - } - else + if (_argIndex <= RespFrame.MaxBitmapArg) _keyBitmap |= 1UL << _argIndex; + else _keyBitmap |= RespFrame.TruncatedFlag; // no bit for it; say so rather than report a subset + } + + /// Pack the key marks into the frame's single 64-bit field. + /// + /// Two keys or fewer keep the byte offsets, which resolve with no scan and do not care how far out + /// the arguments were. Beyond that the bitmap is the only form that fits, and resolving it costs a + /// walk - see . + /// + private readonly ulong PackKeyMarks() + { + if (_keyCount == 0) return 0; + if (_keyCount <= 2) { - // a third key: the inline offsets cannot express it, so fall back to a walk - _keyMarks = RespFrame.OverflowFlag; + return ((ulong)_keyOffsetA & RespFrame.SlotMask) + | (((ulong)_keyOffsetB & RespFrame.SlotMask) << RespFrame.SlotBits); } + + return RespFrame.OverflowFlag | _keyBitmap; } /// diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 84b76967e..f0a084e17 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -13,10 +13,19 @@ namespace StackExchange.Redis.Interpolated [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public struct RespFrame : IDisposable { - // key marks: MSB clear => up to two 31-bit BUFFER-ABSOLUTE byte offsets, resolvable with no scan; - // MSB set => the frame must be walked to recover keys. Zero means "no keys" - offset 0 can never be - // a key, because the frame starts '*N\r\n'. + // Key marks, two alternative encodings in one 64-bit field: + // + // MSB clear => up to two 31-bit BUFFER-ABSOLUTE byte offsets, resolvable with no scan. Zero means + // "no keys" - offset 0 can never be a key, because the frame starts '*N\r\n'. + // MSB set => a bitmap of the ARGUMENT INDICES that are keys (bits 1..62; argument 0 is always + // the command). Resolving those to byte ranges needs a walk of the frame. + // + // Bit 0 is free in bitmap mode - argument 0 is the command and can never be a key - so it carries + // "truncated": there was a key at an argument index above 62 that could not be recorded. Such a + // frame cannot report its keys at all, and TryGetKeys says so rather than reporting a subset. internal const ulong OverflowFlag = 1UL << 63; + internal const ulong TruncatedFlag = 1UL << 0; + internal const int MaxBitmapArg = 62; internal const int SlotBits = 31; internal const ulong SlotMask = (1UL << SlotBits) - 1; @@ -51,17 +60,112 @@ internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, public readonly bool HasNoKeys => _keyMarks == 0; /// - /// Recover the key payloads without walking the frame. Returns -1 when , - /// in which case the caller must walk instead. + /// How many arguments were keys, or -1 when the frame cannot report them. /// + /// + /// The one case that returns -1 is a key at argument index above + /// (62), which the bitmap has no bit for. It is recorded as a single + /// "truncated" flag rather than as a partial list, because a partial list is worse than none: a + /// caller tracking keys for invalidation would believe it had them all. Commands with that many keys + /// are rare and large; callers should decline to cache such a frame. + /// + public readonly int KeyCount + { + get + { + if ((_keyMarks & OverflowFlag) == 0) + { + if (_keyMarks == 0) return 0; + return ((_keyMarks >> SlotBits) & SlotMask) == 0 ? 1 : 2; + } + + return (_keyMarks & TruncatedFlag) != 0 ? -1 : PopCount(_keyMarks & ~OverflowFlag); + } + } + + private static int PopCount(ulong value) + { +#if NET6_0_OR_GREATER + return System.Numerics.BitOperations.PopCount(value); +#else + // no BitOperations down-level; this is the standard SWAR popcount + value -= (value >> 1) & 0x5555555555555555UL; + value = (value & 0x3333333333333333UL) + ((value >> 2) & 0x3333333333333333UL); + value = (value + (value >> 4)) & 0x0F0F0F0F0F0F0F0FUL; + return (int)((value * 0x0101010101010101UL) >> 56); +#endif + } + + /// + /// Recover the key payloads. Returns the number written, or -1 if + /// is too small or the frame cannot report its keys - see + /// , which sizes the buffer and distinguishes the two. + /// + /// + /// One or two keys resolve straight from the stored offsets. More than that resolves from the + /// argument-index bitmap, which needs a walk of the frame - cheap (the bytes are in L1 and it is + /// simple length-prefixed skipping) but no longer O(1). Callers that do this per lookup rather than + /// once per frame should cache the result. + /// public readonly int TryGetKeys(scoped Span target) { - if ((_keyMarks & OverflowFlag) != 0) return -1; - var count = 0; - var a = (int)(_keyMarks & SlotMask); - var b = (int)((_keyMarks >> SlotBits) & SlotMask); - if (a != 0) target[count++] = PayloadOf(a); - if (b != 0) target[count++] = PayloadOf(b); + if ((_keyMarks & OverflowFlag) == 0) + { + var count = 0; + var a = (int)(_keyMarks & SlotMask); + var b = (int)((_keyMarks >> SlotBits) & SlotMask); + var needed = (a != 0 ? 1 : 0) + (b != 0 ? 1 : 0); + if (target.Length < needed) return -1; + if (a != 0) target[count++] = PayloadOf(a); + if (b != 0) target[count++] = PayloadOf(b); + return count; + } + + if ((_keyMarks & TruncatedFlag) != 0) return -1; + + var bitmap = _keyMarks & ~OverflowFlag; + if (target.Length < PopCount(bitmap)) return -1; + return WalkKeys(bitmap, target); + } + + /// + /// Resolve argument indices to payload ranges by walking the frame. + /// + /// + /// A rendered frame is *N\r\n followed by N bulk strings, so this is length-prefixed + /// skipping - no RespReader, no allocation. Argument 0 is the command, matching the indices + /// the writer recorded. + /// + private readonly int WalkKeys(ulong bitmap, scoped Span target) + { + var buffer = _buffer!; + var end = _start + _length; + + var i = _start; + while (buffer[i] != (byte)'\n') i++; // past the '*N\r\n' header + i++; + + int arg = 0, count = 0; + while (i < end) + { + var j = i + 1; // past the '$' + var length = 0; + while (buffer[j] != (byte)'\r') + { + length = (length * 10) + (buffer[j] - (byte)'0'); + j++; + } + + var payload = j + 2; + if (arg <= MaxBitmapArg && (bitmap & (1UL << arg)) != 0) + { + target[count++] = new KeyRange(payload, length); + } + + i = payload + length + 2; + arg++; + } + return count; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6ebbc5206..c97fe2c74 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -74,6 +74,7 @@ [SER010]StackExchange.Redis.Interpolated.RespFrame.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.HasNoKeys.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespFrame.KeyCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespFrame.KeysNeedScan.get -> bool [SER010]StackExchange.Redis.Interpolated.RespFrame.RespFrame() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame.Slot.get -> int diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 88765c651..9124295c5 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -33,11 +33,15 @@ private static string[] Parse(ReadOnlySpan frame) return args; } + // sized from KeyCount, NOT a fixed two: a fixed buffer makes TryGetKeys return -1 for "target too + // small", which is indistinguishable here from "frame cannot report its keys" and would let a test + // claiming the latter pass for the former reason private static string[] Keys(in RespFrame frame) { - Span ranges = stackalloc KeyRange[2]; - var count = frame.TryGetKeys(ranges); - if (count < 0) return null!; // caller must scan + var count = frame.KeyCount; + if (count < 0) return null!; // the frame genuinely cannot report them + var ranges = new KeyRange[count]; + Assert.Equal(count, frame.TryGetKeys(ranges)); var keys = new string[count]; for (int i = 0; i < count; i++) keys[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); return keys; @@ -237,14 +241,17 @@ public void OneAndTwoKeysResolveWithoutScanning() } [Fact] - public void ThreeKeysFallBackToScanning() + public void ThreeKeysResolveViaTheBitmap() { var ctx = new RespContext(); using var frame = ctx.Execute($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + // past the two inline offsets, so resolving needs a walk - but the keys ARE recoverable; the + // writer records every key's argument index as well as the first two offsets Assert.True(frame.KeysNeedScan); - Assert.Null(Keys(frame)); - Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); // still renders correctly + Assert.Equal(3, frame.KeyCount); + Assert.Equal(new[] { "a", "b", "c" }, Keys(frame)); + Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); } [Fact] @@ -439,6 +446,7 @@ public void ComposeWithNoInterpolationAtAll() Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); Assert.Equal(4, frame.ArgCount); Assert.True(frame.KeysNeedScan); // three keys exceeds the two inline offsets + Assert.Equal(new[] { "a", "b", "c" }, Keys(frame)); // still recoverable, via the bitmap } [Fact] diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index fef73c04d..e6c35c0e9 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -205,21 +205,97 @@ public void InvalidationBeforeTheFillStartsDoesNotBlockIt() Assert.Equal("$5|fresh|", text); } - [Fact] - public void FramesWhoseKeysCannotBeEnumeratedAreNotCached() + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void ThreeKeyCommandsCacheAndInvalidateOnAnyKey(int which) { using var cache = new RespClientCache(); - // three keys exceeds the two inline marks, and the overflow path records nothing usable - so the - // keys cannot be named, so the entry could never be invalidated. Refusing is the safe answer. - var frame = Ctx.Execute($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); - Assert.True(frame.KeysNeedScan); + var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + Assert.True(frame.KeysNeedScan); // beyond the two inline offsets: resolved from the bitmap + Assert.Equal(3, frame.KeyCount); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(cache.TryComplete(fill, Utf8("*3\r\n$1\r\n1\r\n$1\r\n2\r\n$1\r\n3\r\n"))); + + Assert.True(ThreeKeyHit(cache)); + Assert.True(cache.OnInvalidate(Utf8(((char)('a' + which)).ToString()))); + Assert.False(ThreeKeyHit(cache)); + + static bool ThreeKeyHit(RespClientCache cache) + { + using var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + if (!cache.TryGet(probe.AsLookupKey(), 0, out var payload)) return false; + payload.Release(); + return true; + } + } + + [Fact] + public void BitmapResolvesTheSameRangesTheOffsetsWould() + { + // the two encodings must agree where they overlap, or a frame's keys would depend on how many + // other keys happened to be present + using var two = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}"); + Assert.False(two.KeysNeedScan); + Assert.Equal(new[] { "alpha", "beta" }, KeyStrings(two)); + + using var three = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisKey)"gamma"}"); + Assert.True(three.KeysNeedScan); + Assert.Equal(new[] { "alpha", "beta", "gamma" }, KeyStrings(three)); + } + + [Fact] + public void KeysAreFoundAmongNonKeyArguments() + { + // the bitmap indexes ARGUMENTS, so values interleaved with keys must not shift the walk + using var frame = Ctx.Execute( + $"{RedisCommand.MSET}{(RedisKey)"k1"}{(RedisValue)"v1"}{(RedisKey)"k2"}{(RedisValue)"v2"}{(RedisKey)"k3"}{(RedisValue)"v3"}"); + Assert.Equal(3, frame.KeyCount); + Assert.Equal(new[] { "k1", "k2", "k3" }, KeyStrings(frame)); + } + + [Fact] + public void KeysBeyondTheBitmapAreReportedAsUnavailableNotAsASubset() + { + var handler = new RespCommandHandler(0, 70, Ctx, "MGET"); + for (var i = 0; i < 70; i++) handler.AppendFormatted((RedisKey)("k" + i)); + var frame = handler.Complete(); + + // argument 63 and beyond have no bit; reporting the first 62 would be worse than reporting none, + // because a caller tracking keys for invalidation would believe it had them all + Assert.Equal(-1, frame.KeyCount); + Span ranges = stackalloc KeyRange[70]; + Assert.Equal(-1, frame.TryGetKeys(ranges)); + + using var cache = new RespClientCache(); Assert.False(cache.TryBeginFill(ref frame, 0, out _)); frame.Dispose(); - Assert.Equal(0, cache.Count); } + [Fact] + public void TooSmallATargetIsRejectedRatherThanTruncated() + { + using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + Span small = stackalloc KeyRange[2]; + Assert.Equal(-1, frame.TryGetKeys(small)); + + Span exact = stackalloc KeyRange[3]; + Assert.Equal(3, frame.TryGetKeys(exact)); + } + + private static string[] KeyStrings(in RespFrame frame) + { + var count = frame.KeyCount; + var ranges = new KeyRange[count]; + Assert.Equal(count, frame.TryGetKeys(ranges)); + var result = new string[count]; + for (var i = 0; i < count; i++) result[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); + return result; + } + [Fact] public void MultiKeyEntryIsInvalidatedByAnyOfItsKeys() { From e2297be33b02b13cda904df511aa3a6f4e647e34 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 18:46:52 +0100 Subject: [PATCH 043/360] Add GetOrExecute: the only read-through shape that is correct by construction The obvious hand-written form - TryGet, miss, Execute, Add - cannot detect an invalidation that arrives during Execute, because by the add there is nothing left to compare against, and the server will not repeat it. That is a permanently stale entry rather than a transient one. GetOrExecute captures the generations before calling the executor, so completion can see the world moved; TryBeginFill/TryComplete stay available for callers interleaving their own dispatch. A response that arrives after an invalidation is still RETURNED - a legitimate answer for a read that raced a write - just not stored. TryComplete gains an overload handing back the cached payload retained, so a fill does not need a second lookup to read what it just stored. Also documented what kind of cache this is: read-through via GetOrExecute, cache-aside via the raw pair, and no write path at all - coherence comes from server invalidation, so neither write-through nor write-behind applies. Two consequences recorded: updating a cached value on write is not possible even in principle, since entries hold a response frame rather than a value; and NOLOOP reintroduces a write-side hook, being the one case where the write path must call OnInvalidate itself. 4 more tests, including the in-flight invalidation through GetOrExecute and that 'using' returns exactly the caller's reference. --- design/interpolated-resp-writer.md | 38 +++++++++ .../Interpolated/RespClientCache.cs | 83 ++++++++++++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 4 + .../RespClientCacheTests.cs | 73 ++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index b0695d1cc..8276df528 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1018,6 +1018,44 @@ cache at all. Measured (`ClientCacheBenchmarks`): `OnInvalidate` is **~5-6 ns, zero allocation, flat from 1 to 100,000 cached keys** — about 170M invalidations/sec on one thread, for both hits and misses. +#### 6.7 Why `GetOrExecute`, and what kind of cache this is + +The obvious hand-written shape is **wrong**, and not in a way a careful caller can fix: + +```csharp +if (!cache.TryGet(req, out resp)) +{ + resp = Execute(req); + cache.Add(req, resp); // an invalidation between these two lines is lost forever +} +``` + +By the time `Add` runs there is nothing left to compare against, so an invalidation that arrived during +`Execute` cannot be detected — and the server will not repeat it, having dropped the key from its table +when it fired. The result is a *permanently* stale entry. That is why `GetOrExecute` exists: it captures +generations before it calls the executor, so the completion can see that the world moved. It is not sugar; +**it is the only shape that is correct by construction**, and the explicit `TryBeginFill`/`TryComplete` +pair is for callers who need to interleave their own dispatch. + +A response that arrives after an invalidation is still *returned* — it is a legitimate answer for a read +that raced a write, and the caller would have got it anyway without a cache — it is simply not stored. + +**Read-through, and no write path at all.** `GetOrExecute` makes the cache own the fetch, which is +read-through; the raw `TryGet` + `TryBeginFill` pair is cache-aside. Neither write-through nor write-behind +applies, because **writes never go through this cache**. Coherence comes from the server telling us what +changed, which puts this closer to hardware cache coherence than to the application-caching taxonomy: we +hold no dirty state and never write back. + +Two consequences worth stating: + +- **Updating a cached value on write is not an option**, even in principle. Entries are keyed by rendered + frame and hold a response *frame*, so "write through" would mean synthesising what `GET foo` will return + after a `SET foo bar` — possible only for trivial commands and wrong in general. Invalidation is the only + sound answer. +- **`NOLOOP` reintroduces a write-side hook.** It suppresses invalidations for keys this connection + modified, so under `NOLOOP` the write path *must* call `OnInvalidate` itself. That is a write-through + concern in a design that otherwise has none, and it is the one place the "no write path" story breaks. + Pinning also **keeps the key offsets valid**: buffer-absolute offsets stay resolvable for the entry's whole lifetime, so keys can be recovered lazily from a cached entry without re-rendering. Copying would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 34e47ceb0..b2c7fc259 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -6,6 +6,13 @@ namespace StackExchange.Redis.Interpolated { + /// Issue a request and return the raw response bytes. + /// Caller state, so the callback need not close over anything. + /// The caller state. + /// The rendered request frame. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public delegate byte[] RespExecutor(TState state, ReadOnlySpan request); + /// /// EXPERIMENTAL SPIKE. A client-side cache built as two independent lookups rather than a cross-indexed /// structure. @@ -109,7 +116,7 @@ public bool TryGet(in RespCacheKey frame, int database, [NotNullWhen(true)] out /// This is what closes the race the Redis docs describe: an invalidation can arrive between the send /// and the reply, and the server will not tell us again, because it dropped the key from its /// invalidation table when it fired. Caching that reply would leave permanently stale data. By - /// recording generations at send time, can see that the world moved. + /// recording generations at send time, TryComplete can see that the world moved. /// /// /// Returns false - refusing to cache - when the frame cannot report its keys. That is now only @@ -153,7 +160,21 @@ public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) /// /// false if the fill was abandoned; the response must not be cached. public bool TryComplete(in RespFill fill, ReadOnlySpan response) + => TryComplete(fill, response, out var retained) ? Release(retained) : false; + + private static bool Release(RespPayload payload) + { + payload.Release(); + return true; + } + + /// + /// As , also handing back the cached + /// payload retained so the caller can read it without a second lookup. + /// + public bool TryComplete(in RespFill fill, ReadOnlySpan response, out RespPayload retained) { + retained = null!; if (fill.Key.IsEmpty) return false; if (!Dependency.AllValid(fill.Dependencies)) @@ -172,7 +193,13 @@ public bool TryComplete(in RespFill fill, ReadOnlySpan response) if (_entries.TryAdd(new EntryKey(stored, fill.Database), entry)) { fill.Key.Dispose(); // the dictionary holds its own reference now - return true; + if (entry.Payload.TryRetain()) + { + retained = entry.Payload; + return true; + } + + return false; // evicted already; vanishingly unlikely, but it is a miss, not an error } // somebody else filled the same frame first; theirs is as good as ours @@ -182,6 +209,58 @@ public bool TryComplete(in RespFill fill, ReadOnlySpan response) return false; } + /// + /// Look up, and on a miss execute and cache - with the send-time ordering handled for you. + /// + /// + /// The response, retained. Dispose it (or ) when done; a + /// using does the right thing. + /// + /// + /// + /// Prefer this to calling and a separate add. The obvious hand-written + /// shape - look up, miss, execute, then add - is exactly the unsafe one: an invalidation arriving + /// while the command is in flight is lost, because by the time the add happens there is nothing left + /// to compare against. This method captures the generations before it calls + /// , so TryComplete can see that the world moved. + /// + /// + /// A response that arrives after an invalidation is still returned - it is a legitimate answer + /// for a read that raced a write, and the caller would have got it anyway without a cache - it is + /// simply not stored. + /// + /// + /// exists so the callback can be a static lambda and allocate no + /// closure. Returning byte[] is a spike convenience: the real thing would hand back the + /// response frame's own lease, as RespResult already does, rather than copying. + /// + /// + /// Caller state, passed to . + /// The rendered request; its buffer is taken over when the response is cached. + /// The database the request runs against. + /// Caller state, so the callback need not close over anything. + /// Issues the request when the cache misses. + public RespPayload GetOrExecute( + ref RespFrame frame, + int database, + TState state, + RespExecutor execute) + { + if (execute is null) throw new ArgumentNullException(nameof(execute)); + + if (TryGet(frame.AsLookupKey(), database, out var hit)) return hit; + + if (!TryBeginFill(ref frame, database, out var fill)) + { + // keys not nameable, so not cacheable - but the caller still wants an answer + return RespPayload.Create(execute(state, frame.Span)); + } + + // the frame's buffer now belongs to the fill, so read the request from there + var response = execute(state, fill.Key.Span); + return TryComplete(fill, response, out var stored) ? stored : RespPayload.Create(response); + } + /// /// Drop entries that no longer validate, releasing their payloads and keys. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index c97fe2c74..bcd916ced 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -24,6 +24,7 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.GetOrExecute(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, TState state, StackExchange.Redis.Interpolated.RespExecutor! execute) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void @@ -34,6 +35,7 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, System.ReadOnlySpan response) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, System.ReadOnlySpan response, out StackExchange.Redis.Interpolated.RespPayload! retained) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespCacheKey frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool [SER010]StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void @@ -63,6 +65,7 @@ [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespExecutor [SER010]StackExchange.Redis.Interpolated.RespFragment [SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespFragment.Bytes.get -> System.ReadOnlySpan @@ -88,4 +91,5 @@ [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]virtual StackExchange.Redis.Interpolated.RespExecutor.Invoke(TState state, System.ReadOnlySpan request) -> byte[]! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index e6c35c0e9..f5f146e98 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -286,6 +286,79 @@ public void TooSmallATargetIsRejectedRatherThanTruncated() Assert.Equal(3, frame.TryGetKeys(exact)); } + [Fact] + public void GetOrExecuteRunsOnceThenServesFromCache() + { + using var cache = new RespClientCache(); + var calls = 0; + + for (var i = 0; i < 3; i++) + { + var frame = Get("abc"); + using var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => + { + calls++; + return Utf8("$5\r\nhello\r\n"); + }); + + Assert.Equal("$5|hello|", Text(resp.Span)); + } + + Assert.Equal(1, calls); + } + + [Fact] + public void GetOrExecuteStillAnswersWhenInvalidatedInFlight() + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + + // the write lands while our command is in flight - the shape that the hand-written + // "miss, execute, then add" cannot detect, because by the add there is nothing left to compare + using (var resp = cache.GetOrExecute(ref frame, 0, cache, (c, _) => + { + c.OnInvalidate(Utf8("abc")); + return Utf8("$5\r\nhello\r\n"); + })) + { + Assert.Equal("$5|hello|", Text(resp.Span)); // the caller still gets an answer + } + + Assert.Equal(0, cache.Count); // ... it just was not cached + } + + [Fact] + public void GetOrExecuteAnswersEvenWhenTheFrameCannotBeCached() + { + using var cache = new RespClientCache(); + var handler = new RespCommandHandler(0, 70, Ctx, "MGET"); + for (var i = 0; i < 70; i++) handler.AppendFormatted((RedisKey)("k" + i)); + var frame = handler.Complete(); + + using (var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => Utf8("$2\r\nok\r\n"))) + { + Assert.Equal("$2|ok|", Text(resp.Span)); + } + + Assert.Equal(0, cache.Count); + frame.Dispose(); + } + + [Fact] + public void GetOrExecutePayloadIsReleasedByUsing() + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + RespPayload captured; + using (var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => Utf8("$5\r\nhello\r\n"))) + { + captured = resp; + Assert.Equal(2, captured.RefCount); // the cache entry, plus ours + } + + Assert.Equal(1, captured.RefCount); // 'using' gave ours back; the cache keeps its own + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From a5686e56f935acdc7ce37de5310bc41ef6fe5f08 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 18:52:22 +0100 Subject: [PATCH 044/360] GetOrExecute takes a command interface and owns every lifetime Replaces the delegate pair, which handed the caller back a retained payload and left three things to get right. Now IRespCommand carries both halves - Execute and Parse - because the cache has to sequence them, and that lets the helper internalise all three: generations captured before the send - the helper calls Execute itself payload retained across the parse - Parse runs inside the window, released in a finally request frame consumed on every path - whether it became a cache key or not The call site is now a single expression with no using, nothing to release, and no ordering to observe. A reply arriving after an in-flight invalidation is still parsed and returned - it is a legitimate answer for a read that raced a write - just not stored. The command is passed as an interface, so callers hold one instance and reuse it; a struct implementation would box per call. Documented on the type. Two tests pin the parts that were previously the caller's problem: exactly one reference survives GetOrExecute on both hit and miss paths (a leak would strand the buffer, an over-release would leave the entry reading freed bytes), and the frame is consumed on every path. Also fixed a leak in my own new test, which detached a key inline and never released it. --- design/interpolated-resp-writer.md | 21 +++ .../Interpolated/RespClientCache.cs | 122 ++++++++++++------ .../PublicAPI/PublicAPI.Unshipped.txt | 7 +- .../RespClientCacheTests.cs | 90 ++++++++----- 4 files changed, 165 insertions(+), 75 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 8276df528..3a2c28d5c 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1040,6 +1040,27 @@ pair is for callers who need to interleave their own dispatch. A response that arrives after an invalidation is still *returned* — it is a legitimate answer for a read that raced a write, and the caller would have got it anyway without a cache — it is simply not stored. +`GetOrExecute` takes an `IRespCommand` carrying **both** halves — how to issue the request and how +to read the reply — rather than returning bytes for the caller to handle. Both halves together, because the +cache has to *sequence* them, and that internalises three lifetimes in descending order of how easy each is +to get wrong: + +| | Handled by | +| --- | --- | +| Generations captured **before** the send | the helper calls `Execute` itself | +| Payload retained across the parse, released in a `finally` | `Parse` is called inside the window | +| The request frame consumed on **every** path | `ref RespFrame`, neutered whether it became a key or not | + +None of the three is visible at the call site, which reduces to: + +```csharp +var req = ctx.Execute($"{RedisCommand.GET}{key}"); +return cache.GetOrExecute(ref req, db, command); // no using, nothing to release, nothing to order +``` + +The command is passed as an interface, so hold **one instance and reuse it** — a `struct` implementation +would box per call. A reused instance allocates nothing per request. + **Read-through, and no write path at all.** `GetOrExecute` makes the cache own the fetch, which is read-through; the raw `TryGet` + `TryBeginFill` pair is cache-aside. Neither write-through nor write-behind applies, because **writes never go through this cache**. Coherence comes from the server telling us what diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index b2c7fc259..cd8a971e1 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -6,12 +6,42 @@ namespace StackExchange.Redis.Interpolated { - /// Issue a request and return the raw response bytes. - /// Caller state, so the callback need not close over anything. - /// The caller state. - /// The rendered request frame. + /// + /// EXPERIMENTAL SPIKE. The two halves of a command the cache needs to own: how to issue it, and how to + /// read the reply. + /// + /// What parsing the reply produces. + /// + /// + /// Both halves together, rather than as separate callbacks, because the cache has to sequence them: the + /// key generations are captured before and must run inside the + /// window where the payload is retained. Handing the cache one object means no caller can get that + /// order wrong, or forget to release, or read the bytes after releasing. + /// + /// + /// Hold one instance and reuse it - it is passed as an interface, so a struct implementation + /// would box on every call. A reused instance allocates nothing per request. + /// + /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public delegate byte[] RespExecutor(TState state, ReadOnlySpan request); + public interface IRespCommand + { + /// Issue the rendered request and return the raw reply. + /// The rendered request frame. + /// + /// Returning byte[] is a spike convenience; the real thing would hand back the reply frame's + /// own lease, as RespResult already does, rather than copying. + /// + byte[] Execute(ReadOnlySpan request); + + /// Read a reply - cached or fresh - into a result. + /// The reply bytes; valid only for the duration of this call. + /// + /// Do not let escape. The bytes belong to a pooled buffer that is + /// released as soon as this returns, and may then be serving another request entirely. + /// + TResult Parse(ReadOnlySpan response); + } /// /// EXPERIMENTAL SPIKE. A client-side cache built as two independent lookups rather than a cross-indexed @@ -210,55 +240,73 @@ public bool TryComplete(in RespFill fill, ReadOnlySpan response, out RespP } /// - /// Look up, and on a miss execute and cache - with the send-time ordering handled for you. + /// Look up, and on a miss issue the command and cache the reply. Everything the caller could get + /// wrong is handled inside. /// - /// - /// The response, retained. Dispose it (or ) when done; a - /// using does the right thing. - /// + /// What parsing the reply produces. + /// + /// The rendered request. This method takes ownership on every path - do not dispose it, and + /// do not use it afterwards. + /// + /// The database the request runs against. + /// How to issue the request and read the reply. /// /// - /// Prefer this to calling and a separate add. The obvious hand-written - /// shape - look up, miss, execute, then add - is exactly the unsafe one: an invalidation arriving - /// while the command is in flight is lost, because by the time the add happens there is nothing left - /// to compare against. This method captures the generations before it calls - /// , so TryComplete can see that the world moved. + /// This is the shape to use. The obvious hand-written alternative - look up, miss, execute, + /// then add - is unsafe and cannot be repaired by the caller: an invalidation arriving while the + /// command is in flight is lost, because by the time the add runs there is nothing left to compare + /// against, and the server will not repeat it. The result is a permanently stale entry. Here the key + /// generations are captured before is called. /// /// - /// A response that arrives after an invalidation is still returned - it is a legitimate answer - /// for a read that raced a write, and the caller would have got it anyway without a cache - it is - /// simply not stored. + /// Three lifetimes are internalised, in order of how easy each is to get wrong: the payload is + /// retained across and released in a finally; the + /// frame is consumed on every path, whether it became a cache key or not; and the send/capture + /// ordering above. None of them is visible to the caller. /// /// - /// exists so the callback can be a static lambda and allocate no - /// closure. Returning byte[] is a spike convenience: the real thing would hand back the - /// response frame's own lease, as RespResult already does, rather than copying. + /// A reply that arrives after an invalidation is still parsed and returned - it is a + /// legitimate answer for a read that raced a write, and the caller would have got it anyway without + /// a cache - it is simply not stored. /// /// - /// Caller state, passed to . - /// The rendered request; its buffer is taken over when the response is cached. - /// The database the request runs against. - /// Caller state, so the callback need not close over anything. - /// Issues the request when the cache misses. - public RespPayload GetOrExecute( - ref RespFrame frame, - int database, - TState state, - RespExecutor execute) + public TResult GetOrExecute(ref RespFrame frame, int database, IRespCommand command) { - if (execute is null) throw new ArgumentNullException(nameof(execute)); + if (command is null) throw new ArgumentNullException(nameof(command)); - if (TryGet(frame.AsLookupKey(), database, out var hit)) return hit; + if (TryGet(frame.AsLookupKey(), database, out var hit)) + { + frame.Dispose(); + try + { + return command.Parse(hit.Span); + } + finally + { + hit.Release(); + } + } if (!TryBeginFill(ref frame, database, out var fill)) { // keys not nameable, so not cacheable - but the caller still wants an answer - return RespPayload.Create(execute(state, frame.Span)); + var uncacheable = command.Execute(frame.Span); + frame.Dispose(); + return command.Parse(uncacheable); } - // the frame's buffer now belongs to the fill, so read the request from there - var response = execute(state, fill.Key.Span); - return TryComplete(fill, response, out var stored) ? stored : RespPayload.Create(response); + // the frame's buffer belongs to the fill now, so the request reads from there + var response = command.Execute(fill.Key.Span); + if (!TryComplete(fill, response, out var stored)) return command.Parse(response); + + try + { + return command.Parse(stored.Span); + } + finally + { + stored.Release(); + } } /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index bcd916ced..6a4ae492a 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -2,6 +2,9 @@ [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.ToString() -> string! +[SER010]StackExchange.Redis.Interpolated.IRespCommand +[SER010]StackExchange.Redis.Interpolated.IRespCommand.Execute(System.ReadOnlySpan request) -> byte[]! +[SER010]StackExchange.Redis.Interpolated.IRespCommand.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.KeyRange [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void @@ -24,7 +27,7 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.GetOrExecute(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, TState state, StackExchange.Redis.Interpolated.RespExecutor! execute) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]StackExchange.Redis.Interpolated.RespClientCache.GetOrExecute(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, StackExchange.Redis.Interpolated.IRespCommand! command) -> TResult [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void @@ -65,7 +68,6 @@ [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext -[SER010]StackExchange.Redis.Interpolated.RespExecutor [SER010]StackExchange.Redis.Interpolated.RespFragment [SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespFragment.Bytes.get -> System.ReadOnlySpan @@ -91,5 +93,4 @@ [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! -[SER010]virtual StackExchange.Redis.Interpolated.RespExecutor.Invoke(TState state, System.ReadOnlySpan request) -> byte[]! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index f5f146e98..062499552 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -286,45 +286,49 @@ public void TooSmallATargetIsRejectedRatherThanTruncated() Assert.Equal(3, frame.TryGetKeys(exact)); } + /// A command whose reply is a fixed blob; counts how often it was actually issued. + private sealed class FakeCommand(string response, Action? onExecute = null) : IRespCommand + { + public int Executed { get; private set; } + + public byte[] Execute(ReadOnlySpan request) + { + Executed++; + onExecute?.Invoke(); + return Utf8(response); + } + + public string Parse(ReadOnlySpan response) => Text(response); + } + [Fact] public void GetOrExecuteRunsOnceThenServesFromCache() { using var cache = new RespClientCache(); - var calls = 0; + var command = new FakeCommand("$5\r\nhello\r\n"); for (var i = 0; i < 3; i++) { + // note: no 'using' on the frame and none on any payload - the helper owns both var frame = Get("abc"); - using var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => - { - calls++; - return Utf8("$5\r\nhello\r\n"); - }); - - Assert.Equal("$5|hello|", Text(resp.Span)); + Assert.Equal("$5|hello|", cache.GetOrExecute(ref frame, 0, command)); } - Assert.Equal(1, calls); + Assert.Equal(1, command.Executed); } [Fact] public void GetOrExecuteStillAnswersWhenInvalidatedInFlight() { using var cache = new RespClientCache(); - var frame = Get("abc"); - // the write lands while our command is in flight - the shape that the hand-written + // the write lands while our command is in flight - the shape that a hand-written // "miss, execute, then add" cannot detect, because by the add there is nothing left to compare - using (var resp = cache.GetOrExecute(ref frame, 0, cache, (c, _) => - { - c.OnInvalidate(Utf8("abc")); - return Utf8("$5\r\nhello\r\n"); - })) - { - Assert.Equal("$5|hello|", Text(resp.Span)); // the caller still gets an answer - } + var command = new FakeCommand("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); - Assert.Equal(0, cache.Count); // ... it just was not cached + var frame = Get("abc"); + Assert.Equal("$5|hello|", cache.GetOrExecute(ref frame, 0, command)); // still answered + Assert.Equal(0, cache.Count); // ... but not cached } [Fact] @@ -335,28 +339,44 @@ public void GetOrExecuteAnswersEvenWhenTheFrameCannotBeCached() for (var i = 0; i < 70; i++) handler.AppendFormatted((RedisKey)("k" + i)); var frame = handler.Complete(); - using (var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => Utf8("$2\r\nok\r\n"))) - { - Assert.Equal("$2|ok|", Text(resp.Span)); - } - + Assert.Equal("$2|ok|", cache.GetOrExecute(ref frame, 0, new FakeCommand("$2\r\nok\r\n"))); Assert.Equal(0, cache.Count); - frame.Dispose(); } [Fact] - public void GetOrExecutePayloadIsReleasedByUsing() + public void GetOrExecuteLeavesNoReferenceBehindOnAnyPath() { using var cache = new RespClientCache(); - var frame = Get("abc"); - RespPayload captured; - using (var resp = cache.GetOrExecute(ref frame, 0, this, (_, _) => Utf8("$5\r\nhello\r\n"))) - { - captured = resp; - Assert.Equal(2, captured.RefCount); // the cache entry, plus ours - } + var command = new FakeCommand("$5\r\nhello\r\n"); + + var fill = Get("abc"); + cache.GetOrExecute(ref fill, 0, command); + + var hit = Get("abc"); + cache.GetOrExecute(ref hit, 0, command); + + // exactly one reference survives - the cache entry's. If the helper leaked the caller's retain the + // buffer would never return to the pool; if it over-released, the entry would be reading freed bytes + using var probe = Get("abc"); // borrow; Detach here would own a lease nothing ever released + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); + Assert.Equal(2, payload.RefCount); // the entry, plus the one TryGet just handed us + payload.Release(); + Assert.Equal(1, payload.RefCount); + } + + [Fact] + public void GetOrExecuteConsumesTheFrameOnEveryPath() + { + using var cache = new RespClientCache(); + var command = new FakeCommand("$5\r\nhello\r\n"); + + var miss = Get("abc"); + cache.GetOrExecute(ref miss, 0, command); + Assert.Throws(() => miss.AsLookupKey()); - Assert.Equal(1, captured.RefCount); // 'using' gave ours back; the cache keeps its own + var hit = Get("abc"); + cache.GetOrExecute(ref hit, 0, command); + Assert.Throws(() => hit.AsLookupKey()); } private static string[] KeyStrings(in RespFrame frame) From 2839e081a62288459bea4b97af9490a6e11f1473 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 18:55:52 +0100 Subject: [PATCH 045/360] Invert ownership: the cache participates in the send, it does not own it Replaces cache.GetOrExecute(frame, db, command) with executor.Send(ref request, handler); // no cache executor.Send(ref request, handler, cache); // with cache Two things were backwards. A cache that calls the executor has to sit above dispatch and know how to send; and a 'command' has no business knowing how to send itself. Splitting gives an executor that sends and a handler that is exactly the ResultProcessor role the library already has - which is a good sign the factoring is right rather than novel. Practical effect: caching is one extra argument rather than a different API, so turning it on does not mean rewriting call sites, and 'no cache' is an ordinary case rather than one with no home. IRespExecutor has a single member to implement; the orchestration is a shared extension method, so the ordering rule that makes caching safe - capture generations before the send - lives in one place we own instead of being exposed to every caller. The three internalised lifetimes are unchanged: generations before the send, payload retained across the parse and released in a finally, and the request frame consumed on every path including the no-cache overload. IRespCommand and RespClientCache.GetOrExecute are removed; TryGet / TryBeginFill / TryComplete remain as the explicit primitives that Send is built from. --- design/interpolated-resp-writer.md | 29 +++- .../Interpolated/RespClientCache.cs | 107 ------------ .../Interpolated/RespExecutor.cs | 158 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 12 +- .../RespClientCacheTests.cs | 86 ++++++---- 5 files changed, 244 insertions(+), 148 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespExecutor.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3a2c28d5c..3f4fb6c15 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1040,14 +1040,27 @@ pair is for callers who need to interleave their own dispatch. A response that arrives after an invalidation is still *returned* — it is a legitimate answer for a read that raced a write, and the caller would have got it anyway without a cache — it is simply not stored. -`GetOrExecute` takes an `IRespCommand` carrying **both** halves — how to issue the request and how -to read the reply — rather than returning bytes for the caller to handle. Both halves together, because the -cache has to *sequence* them, and that internalises three lifetimes in descending order of how easy each is -to get wrong: +**The cache is a participant in the send, not the entry point.** An earlier shape had the cache own the +call (`cache.GetOrExecute(...)`, with the command supplying its own `Execute`). That was backwards twice +over: a cache that calls the executor has to sit above dispatch and know how to send, and a *command* has +no business knowing how to send itself. Inverting it gives the shape the library already has — an executor +that sends, and a handler that is exactly the `ResultProcessor` role: + +```csharp +executor.Send(ref request, handler); // no cache +executor.Send(ref request, handler, cache); // with cache +``` + +Caching becomes one extra argument rather than a different API, so turning it on does not mean rewriting +call sites, and "no cache" is an ordinary case rather than a missing one. `IRespExecutor` has one member to +implement; the orchestration is a shared extension method, so the ordering rule that makes caching safe +lives in exactly one place we own instead of being exposed to every caller. + +That orchestration internalises three lifetimes, in descending order of how easy each is to get wrong: | | Handled by | | --- | --- | -| Generations captured **before** the send | the helper calls `Execute` itself | +| Generations captured **before** the send | `Send` sequences the capture and the send itself | | Payload retained across the parse, released in a `finally` | `Parse` is called inside the window | | The request frame consumed on **every** path | `ref RespFrame`, neutered whether it became a key or not | @@ -1055,11 +1068,11 @@ None of the three is visible at the call site, which reduces to: ```csharp var req = ctx.Execute($"{RedisCommand.GET}{key}"); -return cache.GetOrExecute(ref req, db, command); // no using, nothing to release, nothing to order +return executor.Send(ref req, handler, cache); // no using, nothing to release, nothing to order ``` -The command is passed as an interface, so hold **one instance and reuse it** — a `struct` implementation -would box per call. A reused instance allocates nothing per request. +Executor and handler are passed as interfaces, so hold **one instance of each and reuse them** — a `struct` +implementation would box per call. Reused instances allocate nothing per request. **Read-through, and no write path at all.** `GetOrExecute` makes the cache own the fetch, which is read-through; the raw `TryGet` + `TryBeginFill` pair is cache-aside. Neither write-through nor write-behind diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index cd8a971e1..8a6513eff 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -6,43 +6,6 @@ namespace StackExchange.Redis.Interpolated { - /// - /// EXPERIMENTAL SPIKE. The two halves of a command the cache needs to own: how to issue it, and how to - /// read the reply. - /// - /// What parsing the reply produces. - /// - /// - /// Both halves together, rather than as separate callbacks, because the cache has to sequence them: the - /// key generations are captured before and must run inside the - /// window where the payload is retained. Handing the cache one object means no caller can get that - /// order wrong, or forget to release, or read the bytes after releasing. - /// - /// - /// Hold one instance and reuse it - it is passed as an interface, so a struct implementation - /// would box on every call. A reused instance allocates nothing per request. - /// - /// - [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public interface IRespCommand - { - /// Issue the rendered request and return the raw reply. - /// The rendered request frame. - /// - /// Returning byte[] is a spike convenience; the real thing would hand back the reply frame's - /// own lease, as RespResult already does, rather than copying. - /// - byte[] Execute(ReadOnlySpan request); - - /// Read a reply - cached or fresh - into a result. - /// The reply bytes; valid only for the duration of this call. - /// - /// Do not let escape. The bytes belong to a pooled buffer that is - /// released as soon as this returns, and may then be serving another request entirely. - /// - TResult Parse(ReadOnlySpan response); - } - /// /// EXPERIMENTAL SPIKE. A client-side cache built as two independent lookups rather than a cross-indexed /// structure. @@ -239,76 +202,6 @@ public bool TryComplete(in RespFill fill, ReadOnlySpan response, out RespP return false; } - /// - /// Look up, and on a miss issue the command and cache the reply. Everything the caller could get - /// wrong is handled inside. - /// - /// What parsing the reply produces. - /// - /// The rendered request. This method takes ownership on every path - do not dispose it, and - /// do not use it afterwards. - /// - /// The database the request runs against. - /// How to issue the request and read the reply. - /// - /// - /// This is the shape to use. The obvious hand-written alternative - look up, miss, execute, - /// then add - is unsafe and cannot be repaired by the caller: an invalidation arriving while the - /// command is in flight is lost, because by the time the add runs there is nothing left to compare - /// against, and the server will not repeat it. The result is a permanently stale entry. Here the key - /// generations are captured before is called. - /// - /// - /// Three lifetimes are internalised, in order of how easy each is to get wrong: the payload is - /// retained across and released in a finally; the - /// frame is consumed on every path, whether it became a cache key or not; and the send/capture - /// ordering above. None of them is visible to the caller. - /// - /// - /// A reply that arrives after an invalidation is still parsed and returned - it is a - /// legitimate answer for a read that raced a write, and the caller would have got it anyway without - /// a cache - it is simply not stored. - /// - /// - public TResult GetOrExecute(ref RespFrame frame, int database, IRespCommand command) - { - if (command is null) throw new ArgumentNullException(nameof(command)); - - if (TryGet(frame.AsLookupKey(), database, out var hit)) - { - frame.Dispose(); - try - { - return command.Parse(hit.Span); - } - finally - { - hit.Release(); - } - } - - if (!TryBeginFill(ref frame, database, out var fill)) - { - // keys not nameable, so not cacheable - but the caller still wants an answer - var uncacheable = command.Execute(frame.Span); - frame.Dispose(); - return command.Parse(uncacheable); - } - - // the frame's buffer belongs to the fill now, so the request reads from there - var response = command.Execute(fill.Key.Span); - if (!TryComplete(fill, response, out var stored)) return command.Parse(response); - - try - { - return command.Parse(stored.Span); - } - finally - { - stored.Release(); - } - } - /// /// Drop entries that no longer validate, releasing their payloads and keys. /// diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs new file mode 100644 index 000000000..f80d1087b --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -0,0 +1,158 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Something that can issue a rendered request. + /// + /// + /// One member to implement. The orchestration - cache probe, generation capture, payload lifetime - is + /// in and is shared by every implementation rather than reimplemented by + /// each, which is the point: the ordering rule that makes caching safe lives in one place we own. + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public interface IRespExecutor + { + /// The database requests run against; part of a cached entry's identity. + int Database { get; } + + /// Issue the rendered request and return the raw reply. + /// The rendered request frame. + /// + /// Returning byte[] is a spike convenience; the real thing would hand back the reply frame's + /// own lease, as RespResult already does, rather than copying. + /// + byte[] Send(ReadOnlySpan request); + } + + /// + /// EXPERIMENTAL SPIKE. Turns a reply into a result - the ResultProcessor half. + /// + /// What parsing the reply produces. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public interface IRespHandler + { + /// Read a reply - cached or fresh - into a result. + /// The reply bytes; valid only for the duration of this call. + /// + /// Do not let escape. The bytes belong to a pooled buffer that may be + /// released as soon as this returns, and may then be serving another request entirely. + /// + TResult Parse(ReadOnlySpan response); + } + + /// + /// EXPERIMENTAL SPIKE. Sending a request, with or without a client-side cache. + /// + /// + /// + /// The cache is an optional participant in the send, not the entry point. That keeps the call + /// site identical whether or not caching is configured - executor.Send(request, handler) versus + /// executor.Send(request, handler, cache) - so enabling caching does not mean rewriting callers, + /// and "no cache" is an ordinary case rather than a missing one. + /// + /// + /// It is also the right layering. A cache that called the executor would have to sit above dispatch and + /// know how to send; a cache the executor consults is what it actually is - a client-side concern of + /// the thing doing the sending. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespExecutor + { + /// Send a request and parse the reply, with no caching. + /// What parsing the reply produces. + /// The executor to send through. + /// The rendered request; consumed by this call. + /// Turns the reply into a result. + public static TResult Send( + this IRespExecutor executor, + ref RespFrame request, + IRespHandler handler) + { + if (executor is null) throw new ArgumentNullException(nameof(executor)); + if (handler is null) throw new ArgumentNullException(nameof(handler)); + + var response = executor.Send(request.Span); + request.Dispose(); + return handler.Parse(response); + } + + /// + /// Send a request and parse the reply, serving it from when possible and + /// populating the cache when not. + /// + /// What parsing the reply produces. + /// The executor to send through. + /// The rendered request; consumed by this call on every path. + /// Turns the reply into a result. + /// The cache to consult, or null to bypass caching entirely. + /// + /// + /// Three lifetimes are handled here so that no caller has to, in descending order of how easy each + /// is to get wrong: the key generations are captured before the send, so an invalidation + /// arriving while the command is in flight is detected rather than lost; the payload is retained + /// across and released in a finally; and the + /// request frame is consumed on every path, whether or not it became a cache key. + /// + /// + /// The first of those is the one that cannot be fixed after the fact. Look up, miss, send, then add + /// has nothing left to compare against by the time it adds, and the server does not repeat an + /// invalidation - so the entry would be stale permanently, not briefly. + /// + /// + /// A reply that arrives after an invalidation is still parsed and returned: it is a legitimate answer + /// for a read that raced a write, and the caller would have got it anyway without a cache. It is + /// simply not stored. + /// + /// + public static TResult Send( + this IRespExecutor executor, + ref RespFrame request, + IRespHandler handler, + RespClientCache? cache) + { + if (executor is null) throw new ArgumentNullException(nameof(executor)); + if (handler is null) throw new ArgumentNullException(nameof(handler)); + if (cache is null) return Send(executor, ref request, handler); + + var database = executor.Database; + + if (cache.TryGet(request.AsLookupKey(), database, out var hit)) + { + request.Dispose(); + try + { + return handler.Parse(hit.Span); + } + finally + { + hit.Release(); + } + } + + if (!cache.TryBeginFill(ref request, database, out var fill)) + { + // keys not nameable, so not invalidatable, so not cacheable - but still answerable + var uncacheable = executor.Send(request.Span); + request.Dispose(); + return handler.Parse(uncacheable); + } + + // generations were captured above, BEFORE this line; the frame's buffer belongs to the fill now + var response = executor.Send(fill.Key.Span); + if (!cache.TryComplete(fill, response, out var stored)) return handler.Parse(response); + + try + { + return handler.Parse(stored.Span); + } + finally + { + stored.Release(); + } + } + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6a4ae492a..592a09b42 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -2,9 +2,11 @@ [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespCacheKey.ToString() -> string! -[SER010]StackExchange.Redis.Interpolated.IRespCommand -[SER010]StackExchange.Redis.Interpolated.IRespCommand.Execute(System.ReadOnlySpan request) -> byte[]! -[SER010]StackExchange.Redis.Interpolated.IRespCommand.Parse(System.ReadOnlySpan response) -> TResult +[SER010]StackExchange.Redis.Interpolated.IRespExecutor +[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Database.get -> int +[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(System.ReadOnlySpan request) -> byte[]! +[SER010]StackExchange.Redis.Interpolated.IRespHandler +[SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.KeyRange [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void @@ -27,7 +29,6 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.GetOrExecute(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, StackExchange.Redis.Interpolated.IRespCommand! command) -> TResult [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void @@ -68,6 +69,7 @@ [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespExecutor [SER010]StackExchange.Redis.Interpolated.RespFragment [SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespFragment.Bytes.get -> System.ReadOnlySpan @@ -91,6 +93,8 @@ [SER010]StackExchange.Redis.Interpolated.RespPayload.Release() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 062499552..7ace6d198 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -286,74 +286,98 @@ public void TooSmallATargetIsRejectedRatherThanTruncated() Assert.Equal(3, frame.TryGetKeys(exact)); } - /// A command whose reply is a fixed blob; counts how often it was actually issued. - private sealed class FakeCommand(string response, Action? onExecute = null) : IRespCommand + /// An executor whose reply is a fixed blob; counts how often it was actually asked. + private sealed class FakeExecutor(string response, Action? onSend = null) : IRespExecutor { - public int Executed { get; private set; } + public int Sent { get; private set; } - public byte[] Execute(ReadOnlySpan request) + public int Database => 0; + + public byte[] Send(ReadOnlySpan request) { - Executed++; - onExecute?.Invoke(); + Sent++; + onSend?.Invoke(); return Utf8(response); } + } + + /// The ResultProcessor half: reply bytes in, result out. + private sealed class TextHandler : IRespHandler + { + public static readonly TextHandler Instance = new(); public string Parse(ReadOnlySpan response) => Text(response); } [Fact] - public void GetOrExecuteRunsOnceThenServesFromCache() + public void SendRunsOnceThenServesFromCache() { using var cache = new RespClientCache(); - var command = new FakeCommand("$5\r\nhello\r\n"); + var executor = new FakeExecutor("$5\r\nhello\r\n"); for (var i = 0; i < 3; i++) { - // note: no 'using' on the frame and none on any payload - the helper owns both + // note: no 'using' on the frame and none on any payload - Send owns both var frame = Get("abc"); - Assert.Equal("$5|hello|", cache.GetOrExecute(ref frame, 0, command)); + Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, cache)); } - Assert.Equal(1, command.Executed); + Assert.Equal(1, executor.Sent); } [Fact] - public void GetOrExecuteStillAnswersWhenInvalidatedInFlight() + public void SendWithoutACacheIsTheSameCallShape() + { + var executor = new FakeExecutor("$5\r\nhello\r\n"); + + var a = Get("abc"); + Assert.Equal("$5|hello|", executor.Send(ref a, TextHandler.Instance)); + + // a null cache takes the same overload, so enabling caching is one argument, not a rewrite + var b = Get("abc"); + Assert.Equal("$5|hello|", executor.Send(ref b, TextHandler.Instance, cache: null)); + + Assert.Equal(2, executor.Sent); // no caching either way + } + + [Fact] + public void SendStillAnswersWhenInvalidatedInFlight() { using var cache = new RespClientCache(); // the write lands while our command is in flight - the shape that a hand-written - // "miss, execute, then add" cannot detect, because by the add there is nothing left to compare - var command = new FakeCommand("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); + // "miss, send, then add" cannot detect, because by the add there is nothing left to compare + var executor = new FakeExecutor("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); var frame = Get("abc"); - Assert.Equal("$5|hello|", cache.GetOrExecute(ref frame, 0, command)); // still answered - Assert.Equal(0, cache.Count); // ... but not cached + Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, cache)); // still answered + Assert.Equal(0, cache.Count); // ... not cached } [Fact] - public void GetOrExecuteAnswersEvenWhenTheFrameCannotBeCached() + public void SendAnswersEvenWhenTheFrameCannotBeCached() { using var cache = new RespClientCache(); - var handler = new RespCommandHandler(0, 70, Ctx, "MGET"); - for (var i = 0; i < 70; i++) handler.AppendFormatted((RedisKey)("k" + i)); - var frame = handler.Complete(); + var writer = new RespCommandHandler(0, 70, Ctx, "MGET"); + for (var i = 0; i < 70; i++) writer.AppendFormatted((RedisKey)("k" + i)); + var frame = writer.Complete(); - Assert.Equal("$2|ok|", cache.GetOrExecute(ref frame, 0, new FakeCommand("$2\r\nok\r\n"))); + var executor = new FakeExecutor("$2\r\nok\r\n"); + Assert.Equal("$2|ok|", executor.Send(ref frame, TextHandler.Instance, cache)); Assert.Equal(0, cache.Count); } [Fact] - public void GetOrExecuteLeavesNoReferenceBehindOnAnyPath() + public void SendLeavesNoReferenceBehindOnAnyPath() { using var cache = new RespClientCache(); - var command = new FakeCommand("$5\r\nhello\r\n"); + var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - cache.GetOrExecute(ref fill, 0, command); + executor.Send(ref fill, TextHandler.Instance, cache); var hit = Get("abc"); - cache.GetOrExecute(ref hit, 0, command); + executor.Send(ref hit, TextHandler.Instance, cache); // exactly one reference survives - the cache entry's. If the helper leaked the caller's retain the // buffer would never return to the pool; if it over-released, the entry would be reading freed bytes @@ -365,18 +389,22 @@ public void GetOrExecuteLeavesNoReferenceBehindOnAnyPath() } [Fact] - public void GetOrExecuteConsumesTheFrameOnEveryPath() + public void SendConsumesTheFrameOnEveryPath() { using var cache = new RespClientCache(); - var command = new FakeCommand("$5\r\nhello\r\n"); + var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - cache.GetOrExecute(ref miss, 0, command); + executor.Send(ref miss, TextHandler.Instance, cache); Assert.Throws(() => miss.AsLookupKey()); var hit = Get("abc"); - cache.GetOrExecute(ref hit, 0, command); + executor.Send(ref hit, TextHandler.Instance, cache); Assert.Throws(() => hit.AsLookupKey()); + + var uncached = Get("abc"); + executor.Send(ref uncached, TextHandler.Instance); // the no-cache overload too + Assert.Throws(() => uncached.AsLookupKey()); } private static string[] KeyStrings(in RespFrame frame) From 7bc2ea97835bbec80f40f34f4b4f3402280c16b5 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 19:01:36 +0100 Subject: [PATCH 046/360] Collapse Send to a single method The cached path IS the uncached path plus a probe and a commit, so the two overloads become one: a request that cannot be cached - or a caller with no cache - falls through to the same tail instead of duplicating it. That removes the copy of 'send, dispose, parse' the uncacheable branch was carrying. TryBeginFill leaving the frame owned when it declines is what makes the fall-through work, and is now pinned by a test: the uncacheable path must consume the frame at the tail, like every other path. The pattern is now three members total - IRespExecutor.Send, IRespHandler.Parse, and the one extension method holding all the orchestration. The optional cache parameter is acceptable only because this API is experimental; adding an optional parameter to a shipped method is a binary break, so a shipping version would want overloads for headroom. Noted in the remarks and the design doc. --- design/interpolated-resp-writer.md | 15 ++- .../Interpolated/RespExecutor.cs | 94 +++++++++---------- .../PublicAPI/PublicAPI.Unshipped.txt | 3 +- .../RespClientCacheTests.cs | 4 + 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3f4fb6c15..0d4a857ca 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1052,9 +1052,18 @@ executor.Send(ref request, handler, cache); // with cache ``` Caching becomes one extra argument rather than a different API, so turning it on does not mean rewriting -call sites, and "no cache" is an ordinary case rather than a missing one. `IRespExecutor` has one member to -implement; the orchestration is a shared extension method, so the ordering rule that makes caching safe -lives in exactly one place we own instead of being exposed to every caller. +call sites, and "no cache" is an ordinary case rather than a missing one. + +**The whole pattern is three members.** `IRespExecutor.Send(ReadOnlySpan)`, +`IRespHandler.Parse(ReadOnlySpan)`, and one extension method carrying all the orchestration — +so the ordering rule that makes caching safe lives in exactly one place we own, instead of being exposed to +every caller. (`IRespExecutor.Database` is a fourth, but it is data, not behaviour.) + +One method rather than two overloads, because **the cached path *is* the uncached path plus a probe and a +commit**: a request that cannot be cached — or a caller with no cache — falls through to the same tail +rather than duplicating it. `TryBeginFill` deliberately leaves the frame owned when it declines, which is +what makes that fall-through work. The optional parameter is acceptable only because this is experimental; +adding one to a shipped method is a binary break, so a shipping version would want overloads for headroom. That orchestration internalises three lifetimes, in descending order of how easy each is to get wrong: diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index f80d1087b..7fd124828 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -62,27 +62,9 @@ public interface IRespHandler [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public static class RespExecutor { - /// Send a request and parse the reply, with no caching. - /// What parsing the reply produces. - /// The executor to send through. - /// The rendered request; consumed by this call. - /// Turns the reply into a result. - public static TResult Send( - this IRespExecutor executor, - ref RespFrame request, - IRespHandler handler) - { - if (executor is null) throw new ArgumentNullException(nameof(executor)); - if (handler is null) throw new ArgumentNullException(nameof(handler)); - - var response = executor.Send(request.Span); - request.Dispose(); - return handler.Parse(response); - } - /// - /// Send a request and parse the reply, serving it from when possible and - /// populating the cache when not. + /// Send a request and parse the reply, optionally serving it from - and populating - + /// . /// /// What parsing the reply produces. /// The executor to send through. @@ -91,6 +73,11 @@ public static TResult Send( /// The cache to consult, or null to bypass caching entirely. /// /// + /// One method, because the cached path IS the uncached path plus a probe and a commit: a request + /// that cannot be cached - or a caller with no cache - simply falls through to the bottom of this + /// method rather than duplicating it. + /// + /// /// Three lifetimes are handled here so that no caller has to, in descending order of how easy each /// is to get wrong: the key generations are captured before the send, so an invalidation /// arriving while the command is in flight is detected rather than lost; the payload is retained @@ -107,52 +94,61 @@ public static TResult Send( /// for a read that raced a write, and the caller would have got it anyway without a cache. It is /// simply not stored. /// + /// + /// is optional rather than a second overload only because this API is + /// experimental; adding an optional parameter to a shipped method is a binary break, so a shipping + /// version would want overloads for headroom. + /// /// public static TResult Send( this IRespExecutor executor, ref RespFrame request, IRespHandler handler, - RespClientCache? cache) + RespClientCache? cache = null) { if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); - if (cache is null) return Send(executor, ref request, handler); - var database = executor.Database; - - if (cache.TryGet(request.AsLookupKey(), database, out var hit)) + if (cache is not null) { - request.Dispose(); - try + var database = executor.Database; + + if (cache.TryGet(request.AsLookupKey(), database, out var hit)) { - return handler.Parse(hit.Span); + request.Dispose(); + try + { + return handler.Parse(hit.Span); + } + finally + { + hit.Release(); + } } - finally + + if (cache.TryBeginFill(ref request, database, out var fill)) { - hit.Release(); + // generations were captured above, BEFORE this send; the buffer belongs to the fill now + var filled = executor.Send(fill.Key.Span); + if (!cache.TryComplete(fill, filled, out var stored)) return handler.Parse(filled); + + try + { + return handler.Parse(stored.Span); + } + finally + { + stored.Release(); + } } - } - if (!cache.TryBeginFill(ref request, database, out var fill)) - { - // keys not nameable, so not invalidatable, so not cacheable - but still answerable - var uncacheable = executor.Send(request.Span); - request.Dispose(); - return handler.Parse(uncacheable); + // keys not nameable, so not invalidatable, so not cacheable - which is precisely the + // uncached case, so fall through to it. TryBeginFill leaves the frame owned on failure. } - // generations were captured above, BEFORE this line; the frame's buffer belongs to the fill now - var response = executor.Send(fill.Key.Span); - if (!cache.TryComplete(fill, response, out var stored)) return handler.Parse(response); - - try - { - return handler.Parse(stored.Span); - } - finally - { - stored.Release(); - } + var response = executor.Send(request.Span); + request.Dispose(); + return handler.Parse(response); } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 592a09b42..ff179687c 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -93,8 +93,7 @@ [SER010]StackExchange.Redis.Interpolated.RespPayload.Release() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache) -> TResult -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 7ace6d198..0e56c3e1c 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -365,6 +365,10 @@ public void SendAnswersEvenWhenTheFrameCannotBeCached() var executor = new FakeExecutor("$2\r\nok\r\n"); Assert.Equal("$2|ok|", executor.Send(ref frame, TextHandler.Instance, cache)); Assert.Equal(0, cache.Count); + + // this path FALLS THROUGH to the uncached tail rather than duplicating it, so the frame must be + // consumed there too - TryBeginFill leaves it owned when it declines + Assert.Throws(() => frame.AsLookupKey()); } [Fact] From 13b829a9bc121baac19349c423dd0a87c9c61af9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 19:11:23 +0100 Subject: [PATCH 047/360] Executor contract: pooled and refcounted on both sides, sync and async byte[] replies allocate per call, and a span request cannot cross an await or be parked in a backlog for a resend - so it ruled out async AND retries even synchronously. Both sides are now pooled and reference-counted: RespRequest in, RespPayload out, with TryRetain for an executor that needs the bytes past the call. IRespHandler.Parse still takes a span, correctly: parsing is synchronous and runs inside the retained window. RespCacheKey is renamed RespRequest. The bytes about to be sent and the cache key are the same object, and the request is the primary role; 'Send(in RespCacheKey)' read as nonsense. SendAsync is deliberately NOT an async method: async forbids ref parameters, and the frame must be consumed by reference so a caller's copy cannot be disposed twice. The probe and hand-off are synchronous; only the awaiting tail is a separate async method. A cache hit therefore completes synchronously and allocates nothing - no state machine, no Task - which a test asserts via IsCompletedSuccessfully. TryComplete now takes the payload rather than the bytes, so a cached reply is shared with the caller rather than copied into a second pooled buffer. Three new tests: async parity plus the synchronous-hit assertion, an executor parking a retained request as a resending backlog would, and the reply being shared rather than copied. --- design/interpolated-resp-writer.md | 31 ++- .../Interpolated/RespClientCache.cs | 59 ++--- .../Interpolated/RespExecutor.cs | 234 ++++++++++++------ .../Interpolated/RespFrame.cs | 10 +- .../{RespCacheKey.cs => RespRequest.cs} | 24 +- .../PublicAPI/PublicAPI.Unshipped.txt | 37 +-- .../ClientCacheBenchmarks.cs | 4 +- .../InterpolatedWriterCacheKeyTests.cs | 8 +- .../RespClientCacheTests.cs | 95 ++++++- 9 files changed, 352 insertions(+), 150 deletions(-) rename src/StackExchange.Redis/Interpolated/{RespCacheKey.cs => RespRequest.cs} (83%) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 0d4a857ca..bace95255 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -939,7 +939,7 @@ Two requirements: a 256-byte array for the entry's life — roughly a third overhead on retained bytes. Minor, and a custom chunk pool with buckets fitted to the real frame distribution would tighten it. -#### Implemented: `RespCacheKey` / `RespPayload` (see `InterpolatedWriterCacheKeyTests`) +#### Implemented: `RespRequest` / `RespPayload` (see `InterpolatedWriterCacheKeyTests`) Two corrections to the sketch above, both found by building it. @@ -1054,10 +1054,33 @@ executor.Send(ref request, handler, cache); // with cache Caching becomes one extra argument rather than a different API, so turning it on does not mean rewriting call sites, and "no cache" is an ordinary case rather than a missing one. -**The whole pattern is three members.** `IRespExecutor.Send(ReadOnlySpan)`, -`IRespHandler.Parse(ReadOnlySpan)`, and one extension method carrying all the orchestration — +**Neither side of the executor is a span, and neither is a `byte[]`.** This is not a detail — it is what +makes the contract usable at all: + +- A **span request** cannot cross an `await`, so it rules out async; and it cannot be parked in a backlog + for a resend after a reconnect, so it rules out retries even when synchronous. +- A **`byte[]` reply** allocates on every call, which is the cost this design exists to remove. + +So both sides are pooled and reference-counted: `RespRequest` in, `RespPayload` out. The executor takes its +own reference with `TryRetain` if it needs the bytes past the call; the caller releases theirs either way. +`IRespHandler.Parse` *does* take a span, correctly — parsing is synchronous and runs inside the retained +window. + +This is also why `RespCacheKey` became **`RespRequest`**: the bytes about to be sent and the cache key are +the same object, and the request role is the primary one. + +`SendAsync` is deliberately **not** an `async` method. `async` forbids `ref` parameters, and the frame must +be consumed by reference so a caller's copy cannot be disposed twice; so the probe and hand-off are +synchronous and only the awaiting tail is a separate `async` method. **A cache hit therefore completes +synchronously and allocates nothing** — no state machine, no `Task`. + +`TryComplete` takes the payload rather than the bytes, so a cached reply is **shared with the caller, not +copied**: it is already in a pooled reference-counted buffer, and copying it to cache it would be waste. + +**The whole pattern is three members.** `IRespExecutor.Send`/`SendAsync`, +`IRespHandler.Parse(ReadOnlySpan)`, and the extension pair carrying all the orchestration — so the ordering rule that makes caching safe lives in exactly one place we own, instead of being exposed to -every caller. (`IRespExecutor.Database` is a fourth, but it is data, not behaviour.) +every caller. (`IRespExecutor.Database` is data, not behaviour.) One method rather than two overloads, because **the cached path *is* the uncached path plus a probe and a commit**: a request that cannot be cached — or a caller with no cache — falls through to the same tail diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 8a6513eff..637bb1bbb 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -79,7 +79,7 @@ public sealed class RespClientCache : IDisposable /// Look for a cached response. On success the payload is returned retained - release it when /// the parse is done. /// - public bool TryGet(in RespCacheKey frame, int database, [NotNullWhen(true)] out RespPayload? payload) + public bool TryGet(in RespRequest frame, int database, [NotNullWhen(true)] out RespPayload? payload) { if (_entries.TryGetValue(new EntryKey(frame, database), out var entry) && entry.IsValid @@ -152,22 +152,22 @@ public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) /// command was in flight. /// /// false if the fill was abandoned; the response must not be cached. - public bool TryComplete(in RespFill fill, ReadOnlySpan response) - => TryComplete(fill, response, out var retained) ? Release(retained) : false; - - private static bool Release(RespPayload payload) - { - payload.Release(); - return true; - } - /// - /// As , also handing back the cached - /// payload retained so the caller can read it without a second lookup. + /// Complete a fill, storing the reply only if nothing it depends on was invalidated while the + /// command was in flight. /// - public bool TryComplete(in RespFill fill, ReadOnlySpan response, out RespPayload retained) + /// The fill begun before the send. + /// The reply. The cache takes its OWN reference if it stores it; the caller + /// still releases theirs. + /// false if the fill was abandoned; the reply was not cached. + /// + /// Takes the payload rather than the bytes, so a stored reply is shared with the caller, not + /// copied. It is already in a pooled, reference-counted buffer; copying it into a second one to + /// cache it would be pure waste. + /// + public bool TryComplete(in RespFill fill, RespPayload response) { - retained = null!; + if (response is null) throw new ArgumentNullException(nameof(response)); if (fill.Key.IsEmpty) return false; if (!Dependency.AllValid(fill.Dependencies)) @@ -182,22 +182,23 @@ public bool TryComplete(in RespFill fill, ReadOnlySpan response, out RespP return false; } - var entry = new Entry(RespPayload.Create(response), fill.Dependencies); - if (_entries.TryAdd(new EntryKey(stored, fill.Database), entry)) + if (!response.TryRetain()) { - fill.Key.Dispose(); // the dictionary holds its own reference now - if (entry.Payload.TryRetain()) - { - retained = entry.Payload; - return true; - } + // the reply is already going back to the pool; nothing to cache + stored.Dispose(); + fill.Key.Dispose(); + return false; + } - return false; // evicted already; vanishingly unlikely, but it is a miss, not an error + if (_entries.TryAdd(new EntryKey(stored, fill.Database), new Entry(response, fill.Dependencies))) + { + fill.Key.Dispose(); // the dictionary holds its own references now + return true; } - // somebody else filled the same frame first; theirs is as good as ours + // somebody else filled the same request first; theirs is as good as ours + response.Release(); stored.Dispose(); - entry.Payload.Dispose(); fill.Key.Dispose(); return false; } @@ -268,9 +269,9 @@ private sealed class Entry(RespPayload payload, Dependency[] dependencies) } /// The frame AND the database; see the note on database asymmetry in the type remarks. - private readonly struct EntryKey(RespCacheKey frame, int database) : IEquatable + private readonly struct EntryKey(RespRequest frame, int database) : IEquatable { - internal RespCacheKey Frame { get; } = frame; + internal RespRequest Frame { get; } = frame; private int Database { get; } = database; @@ -285,14 +286,14 @@ private readonly struct EntryKey(RespCacheKey frame, int database) : IEquatable< [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public readonly struct RespFill { - internal RespFill(RespCacheKey key, int database, Dependency[] dependencies) + internal RespFill(RespRequest key, int database, Dependency[] dependencies) { Key = key; Database = database; Dependencies = dependencies; } - internal RespCacheKey Key { get; } + internal RespRequest Key { get; } internal int Database { get; } diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 7fd124828..7da6aee36 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using RESPite; namespace StackExchange.Redis.Interpolated @@ -8,9 +10,19 @@ namespace StackExchange.Redis.Interpolated /// EXPERIMENTAL SPIKE. Something that can issue a rendered request. /// /// - /// One member to implement. The orchestration - cache probe, generation capture, payload lifetime - is - /// in and is shared by every implementation rather than reimplemented by - /// each, which is the point: the ordering rule that makes caching safe lives in one place we own. + /// + /// Neither side is a span, and neither side is a byte[]. A span cannot cross an + /// await, and cannot be parked in a backlog for a resend after a reconnect - so a span request + /// rules out async and rules out retries even when synchronous. A byte[] reply allocates on + /// every call, which is the cost this whole design exists to remove. Both sides are therefore pooled + /// and reference-counted: in, out. + /// + /// + /// Ownership. The caller owns one reference to the request and releases it when the call + /// completes; an implementation that needs the bytes for longer - a backlog, a resend, an unflushed + /// write - takes its own with . The reply is returned with one + /// reference held by the caller, who releases it. Whoever retains, releases. + /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public interface IRespExecutor @@ -18,13 +30,14 @@ public interface IRespExecutor /// The database requests run against; part of a cached entry's identity. int Database { get; } - /// Issue the rendered request and return the raw reply. - /// The rendered request frame. - /// - /// Returning byte[] is a spike convenience; the real thing would hand back the reply frame's - /// own lease, as RespResult already does, rather than copying. - /// - byte[] Send(ReadOnlySpan request); + /// Issue the request and return the reply, with one reference held by the caller. + /// The rendered request; retain it if it must outlive this call. + RespPayload Send(in RespRequest request); + + /// Issue the request asynchronously. + /// The rendered request; retain it if it must outlive this call. + /// Cancels the send. + ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default); } /// @@ -37,8 +50,9 @@ public interface IRespHandler /// Read a reply - cached or fresh - into a result. /// The reply bytes; valid only for the duration of this call. /// - /// Do not let escape. The bytes belong to a pooled buffer that may be - /// released as soon as this returns, and may then be serving another request entirely. + /// A span is right here, unlike on : parsing is synchronous and happens + /// inside the window where the payload is retained. Do not let it escape - the bytes belong to a + /// pooled buffer that may be released as soon as this returns. /// TResult Parse(ReadOnlySpan response); } @@ -47,17 +61,10 @@ public interface IRespHandler /// EXPERIMENTAL SPIKE. Sending a request, with or without a client-side cache. /// /// - /// - /// The cache is an optional participant in the send, not the entry point. That keeps the call - /// site identical whether or not caching is configured - executor.Send(request, handler) versus - /// executor.Send(request, handler, cache) - so enabling caching does not mean rewriting callers, - /// and "no cache" is an ordinary case rather than a missing one. - /// - /// - /// It is also the right layering. A cache that called the executor would have to sit above dispatch and - /// know how to send; a cache the executor consults is what it actually is - a client-side concern of - /// the thing doing the sending. - /// + /// The cache is an optional participant in the send, not the entry point. The call site is + /// identical whether or not caching is configured, so enabling it does not mean rewriting callers, and + /// "no cache" is an ordinary case rather than a missing one. It is also the right layering: a cache that + /// called the executor would have to sit above dispatch and know how to send. /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public static class RespExecutor @@ -72,33 +79,9 @@ public static class RespExecutor /// Turns the reply into a result. /// The cache to consult, or null to bypass caching entirely. /// - /// - /// One method, because the cached path IS the uncached path plus a probe and a commit: a request - /// that cannot be cached - or a caller with no cache - simply falls through to the bottom of this - /// method rather than duplicating it. - /// - /// - /// Three lifetimes are handled here so that no caller has to, in descending order of how easy each - /// is to get wrong: the key generations are captured before the send, so an invalidation - /// arriving while the command is in flight is detected rather than lost; the payload is retained - /// across and released in a finally; and the - /// request frame is consumed on every path, whether or not it became a cache key. - /// - /// - /// The first of those is the one that cannot be fixed after the fact. Look up, miss, send, then add - /// has nothing left to compare against by the time it adds, and the server does not repeat an - /// invalidation - so the entry would be stale permanently, not briefly. - /// - /// - /// A reply that arrives after an invalidation is still parsed and returned: it is a legitimate answer - /// for a read that raced a write, and the caller would have got it anyway without a cache. It is - /// simply not stored. - /// - /// - /// is optional rather than a second overload only because this API is - /// experimental; adding an optional parameter to a shipped method is a binary break, so a shipping - /// version would want overloads for headroom. - /// + /// Three lifetimes are handled here so that no caller has to: the key generations are captured + /// before the send; the payload is retained across + /// and released in a finally; and the request is consumed on every path. /// public static TResult Send( this IRespExecutor executor, @@ -111,44 +94,153 @@ public static TResult Send( if (cache is not null) { - var database = executor.Database; + if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; - if (cache.TryGet(request.AsLookupKey(), database, out var hit)) + if (cache.TryBeginFill(ref request, executor.Database, out var fill)) { - request.Dispose(); + // generations captured above, BEFORE this send + var filled = executor.Send(fill.Key); try { - return handler.Parse(hit.Span); + cache.TryComplete(fill, filled); + return handler.Parse(filled.Span); } finally { - hit.Release(); + filled.Release(); } } - if (cache.TryBeginFill(ref request, database, out var fill)) + // not cacheable, which is precisely the uncached case - fall through to it + } + + // the executor may need the bytes past this call, so hand it something it can retain + var owned = request.Detach(); + try + { + var response = executor.Send(owned); + try + { + return handler.Parse(response.Span); + } + finally { - // generations were captured above, BEFORE this send; the buffer belongs to the fill now - var filled = executor.Send(fill.Key.Span); - if (!cache.TryComplete(fill, filled, out var stored)) return handler.Parse(filled); + response.Release(); + } + } + finally + { + owned.Dispose(); + } + } - try - { - return handler.Parse(stored.Span); - } - finally - { - stored.Release(); - } + /// + /// The executor to send through. + /// The rendered request; consumed by this call on every path. + /// Turns the reply into a result. + /// The cache to consult, or null to bypass caching entirely. + /// Cancels the send. + /// + /// Deliberately not an async method: async forbids ref parameters, and + /// the frame has to be consumed by reference so the caller's copy cannot be used or disposed twice. + /// So the probe and the hand-off happen synchronously here, and only the awaiting tail is a separate + /// async method. A cache hit therefore completes synchronously and allocates nothing - no + /// state machine, no Task. + /// + public static ValueTask SendAsync( + this IRespExecutor executor, + ref RespFrame request, + IRespHandler handler, + RespClientCache? cache = null, + CancellationToken cancellationToken = default) + { + if (executor is null) throw new ArgumentNullException(nameof(executor)); + if (handler is null) throw new ArgumentNullException(nameof(handler)); + + if (cache is not null) + { + if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) + { + return new ValueTask(cached); } - // keys not nameable, so not invalidatable, so not cacheable - which is precisely the - // uncached case, so fall through to it. TryBeginFill leaves the frame owned on failure. + if (cache.TryBeginFill(ref request, executor.Database, out var fill)) + { + return AwaitFill(executor, fill, handler, cache, cancellationToken); + } + } + + return AwaitUncached(executor, request.Detach(), handler, cancellationToken); + } + + // the cache probe is identical for both, and borrows rather than detaching: on a HIT the request + // never reaches the executor, so it never needs an owned lease + private static bool TryServeFromCache( + IRespExecutor executor, + ref RespFrame request, + IRespHandler handler, + RespClientCache cache, + [MaybeNullWhen(false)] out TResult result) + { + if (!cache.TryGet(request.AsLookupKey(), executor.Database, out var hit)) + { + result = default; + return false; } - var response = executor.Send(request.Span); request.Dispose(); - return handler.Parse(response); + try + { + result = handler.Parse(hit.Span); + return true; + } + finally + { + hit.Release(); + } + } + + private static async ValueTask AwaitFill( + IRespExecutor executor, + RespClientCache.RespFill fill, + IRespHandler handler, + RespClientCache cache, + CancellationToken cancellationToken) + { + var response = await executor.SendAsync(fill.Key, cancellationToken).ConfigureAwait(false); + try + { + cache.TryComplete(fill, response); + return handler.Parse(response.Span); + } + finally + { + response.Release(); + } + } + + private static async ValueTask AwaitUncached( + IRespExecutor executor, + RespRequest request, + IRespHandler handler, + CancellationToken cancellationToken) + { + try + { + var response = await executor.SendAsync(request, cancellationToken).ConfigureAwait(false); + try + { + return handler.Parse(response.Span); + } + finally + { + response.Release(); + } + } + finally + { + request.Dispose(); + } } } } diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index f0a084e17..59ebae39a 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -201,18 +201,18 @@ private readonly KeyRange PayloadOf(int offset) /// /// /// The returned key holds ONE reference. Dispose it when done; if it is being stored, take a second - /// with and store that. + /// with and store that. /// /// /// Note the same struct-copy caveat as : this clears ownership on THIS copy of /// the frame, so a copy taken earlier still holds the array reference and must not be disposed. /// /// - public RespCacheKey Detach() + public RespRequest Detach() { var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); _buffer = null; // ownership moves to the lease - return new RespCacheKey(buffer, RefCountedBuffer.Adopt(buffer, buffer.Length), _start, _length); + return new RespRequest(buffer, RefCountedBuffer.Adopt(buffer, buffer.Length), _start, _length); } /// @@ -232,10 +232,10 @@ public RespCacheKey Detach() /// when ownership is actually wanted. /// /// - public RespCacheKey AsLookupKey() + public RespRequest AsLookupKey() { var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); - return new RespCacheKey(buffer, lease: null, _start, _length); + return new RespRequest(buffer, lease: null, _start, _length); } /// Return the underlying buffer to the pool; safe to call more than once. diff --git a/src/StackExchange.Redis/Interpolated/RespCacheKey.cs b/src/StackExchange.Redis/Interpolated/RespRequest.cs similarity index 83% rename from src/StackExchange.Redis/Interpolated/RespCacheKey.cs rename to src/StackExchange.Redis/Interpolated/RespRequest.cs index bc5a8430f..02024ccf2 100644 --- a/src/StackExchange.Redis/Interpolated/RespCacheKey.cs +++ b/src/StackExchange.Redis/Interpolated/RespRequest.cs @@ -8,15 +8,17 @@ namespace StackExchange.Redis.Interpolated { /// - /// EXPERIMENTAL SPIKE. A rendered RESP frame, detached from its builder and usable as a dictionary key - /// without ever being copied into a byte[] or a string. + /// EXPERIMENTAL SPIKE. A rendered RESP request, detached from its builder: the bytes to send, and - the + /// same bytes - the cache key, without ever being copied into a byte[] or a string. /// /// /// - /// This is the client-side-cache half of the interpolated writer: the bytes that were going to be sent - /// anyway ARE the cache key, so a lookup costs a render and no allocation at all. Deliberately not a - /// ref struct - a ref struct cannot be a TKey - which is why the payload lives in a - /// pooled array behind a rather than in a stackalloc. + /// The bytes that were going to be sent anyway ARE the cache key, so a lookup costs a render and no + /// allocation at all. Deliberately not a ref struct: it has to survive as a TKey, cross an + /// await, and be parked in a backlog for a resend - none of which a ref struct or a raw + /// span can do. That is why the bytes live in a pooled array behind a + /// , and why the executor can to hold one + /// past the call. /// /// /// Lifetime. Whoever retains, releases. hands back a key holding @@ -32,7 +34,7 @@ namespace StackExchange.Redis.Interpolated /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public readonly struct RespCacheKey : IEquatable, IDisposable + public readonly struct RespRequest : IEquatable, IDisposable { private readonly byte[]? _array; private readonly RefCountedBuffer? _lease; // null => BORROWED: this key owns no reference @@ -40,7 +42,7 @@ namespace StackExchange.Redis.Interpolated private readonly int _length; private readonly int _hash; - internal RespCacheKey(byte[] array, RefCountedBuffer? lease, int offset, int length) + internal RespRequest(byte[] array, RefCountedBuffer? lease, int offset, int length) { _array = array; _lease = lease; @@ -91,7 +93,7 @@ internal RespCacheKey(byte[] array, RefCountedBuffer? lease, int offset, int len /// and each must be disposed once. The usual shape is retain, try to add, and dispose the retained /// copy if the add lost a race. /// - public bool TryRetain(out RespCacheKey retained) + public bool TryRetain(out RespRequest retained) { if (_lease is not null && _lease.TryAddRef()) { @@ -113,11 +115,11 @@ public bool TryRetain(out RespCacheKey retained) /// different arrays. Canonicality of the rendering is therefore a correctness property - see design /// doc section 6. /// - public bool Equals(RespCacheKey other) + public bool Equals(RespRequest other) => _hash == other._hash && _length == other._length && Span.SequenceEqual(other.Span); /// - public override bool Equals(object? obj) => obj is RespCacheKey other && Equals(other); + public override bool Equals(object? obj) => obj is RespRequest other && Equals(other); /// /// Computed once, when the key is detached, while the bytes are already in cache. diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ff179687c..278e02075 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,10 +1,11 @@ #nullable enable -[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.Equals(object? obj) -> bool -[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.GetHashCode() -> int -[SER010]override StackExchange.Redis.Interpolated.RespCacheKey.ToString() -> string! +[SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool +[SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int +[SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! [SER010]StackExchange.Redis.Interpolated.IRespExecutor [SER010]StackExchange.Redis.Interpolated.IRespExecutor.Database.get -> int -[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(System.ReadOnlySpan request) -> byte[]! +[SER010]StackExchange.Redis.Interpolated.IRespExecutor.SendAsync(StackExchange.Redis.Interpolated.RespRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(in StackExchange.Redis.Interpolated.RespRequest request) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.KeyRange @@ -17,15 +18,6 @@ [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.Tokens.get -> string![]! -[SER010]StackExchange.Redis.Interpolated.RespCacheKey -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Dispose() -> void -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Equals(StackExchange.Redis.Interpolated.RespCacheKey other) -> bool -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.GetReader() -> RESPite.Messages.RespReader -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.IsEmpty.get -> bool -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.IsOwned.get -> bool -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.RespCacheKey() -> void -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.Span.get -> System.ReadOnlySpan -[SER010]StackExchange.Redis.Interpolated.RespCacheKey.TryRetain(out StackExchange.Redis.Interpolated.RespCacheKey retained) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void @@ -38,9 +30,8 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache.Sweep() -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, System.ReadOnlySpan response) -> bool -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, System.ReadOnlySpan response, out StackExchange.Redis.Interpolated.RespPayload! retained) -> bool -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespCacheKey frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, StackExchange.Redis.Interpolated.RespPayload! response) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool [SER010]StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void @@ -76,8 +67,8 @@ [SER010]StackExchange.Redis.Interpolated.RespFragment.RespFragment() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespFrame.ArgCount.get -> int -[SER010]StackExchange.Redis.Interpolated.RespFrame.AsLookupKey() -> StackExchange.Redis.Interpolated.RespCacheKey -[SER010]StackExchange.Redis.Interpolated.RespFrame.Detach() -> StackExchange.Redis.Interpolated.RespCacheKey +[SER010]StackExchange.Redis.Interpolated.RespFrame.AsLookupKey() -> StackExchange.Redis.Interpolated.RespRequest +[SER010]StackExchange.Redis.Interpolated.RespFrame.Detach() -> StackExchange.Redis.Interpolated.RespRequest [SER010]StackExchange.Redis.Interpolated.RespFrame.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.HasNoKeys.get -> bool @@ -93,6 +84,16 @@ [SER010]StackExchange.Redis.Interpolated.RespPayload.Release() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest +[SER010]StackExchange.Redis.Interpolated.RespRequest.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespRequest.Equals(StackExchange.Redis.Interpolated.RespRequest other) -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest.GetReader() -> RESPite.Messages.RespReader +[SER010]StackExchange.Redis.Interpolated.RespRequest.IsEmpty.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest.IsOwned.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest.RespRequest() -> void +[SER010]StackExchange.Redis.Interpolated.RespRequest.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespRequest.TryRetain(out StackExchange.Redis.Interpolated.RespRequest retained) -> bool +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! diff --git a/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs index 5f2e3e9cf..167638cc0 100644 --- a/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs @@ -29,7 +29,9 @@ public void Setup() var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)("key:" + i)}"); if (_cache.TryBeginFill(ref frame, 0, out var fill)) { - _cache.TryComplete(fill, Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + _cache.TryComplete(fill, payload); + payload.Release(); } } diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs index 7c85f1f4f..d5de638e4 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs @@ -14,7 +14,7 @@ namespace StackExchange.Redis.Tests; /// public class InterpolatedWriterCacheKeyTests { - private static RespCacheKey Key(string key) + private static RespRequest Key(string key) { var ctx = new RespContext(); var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); @@ -54,7 +54,7 @@ public void DifferentCommandsAreNotEqual() [Fact] public void WorksAsAConcurrentDictionaryKey() { - var cache = new ConcurrentDictionary(); + var cache = new ConcurrentDictionary(); var stored = Key("abc"); var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); @@ -83,7 +83,7 @@ public void WorksAsAConcurrentDictionaryKey() [Fact] public void LookupAllocatesNothingOnAHit() { - var cache = new ConcurrentDictionary(); + var cache = new ConcurrentDictionary(); var stored = Key("abc"); var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); cache.TryAdd(stored, payload); @@ -103,7 +103,7 @@ public void LookupAllocatesNothingOnAHit() payload.Dispose(); stored.Dispose(); - static void Probe(ConcurrentDictionary cache) + static void Probe(ConcurrentDictionary cache) { // the HIT path borrows rather than detaching: Detach allocates a RefCountedBuffer per call var ctx = new RespContext(); diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 0e56c3e1c..7d932a471 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -19,6 +20,20 @@ public class RespClientCacheTests private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); + /// Complete a fill from raw bytes; the caller's reference is released, as a real one would be. + private static bool Complete(RespClientCache cache, in RespClientCache.RespFill fill, string response) + { + var payload = RespPayload.Create(Utf8(response)); + try + { + return cache.TryComplete(fill, payload); + } + finally + { + payload.Release(); + } + } + private static string Text(ReadOnlySpan value) => Encoding.UTF8.GetString(value.ToArray()).Replace("\r\n", "|"); @@ -27,7 +42,7 @@ private static void Fill(RespClientCache cache, string key, string response, int { var frame = Get(key); Assert.True(cache.TryBeginFill(ref frame, database, out var fill)); - Assert.True(cache.TryComplete(fill, Utf8(response))); + Assert.True(Complete(cache, fill, response)); } private static bool TryRead(RespClientCache cache, string key, out string text, int database = 0) @@ -184,7 +199,7 @@ public void InvalidationDuringFlightRefusesTheFill() cache.OnInvalidate(Utf8("abc")); // ... someone writes the key while we wait for the reply ... - Assert.False(cache.TryComplete(fill, Utf8("$5\r\nstale\r\n"))); + Assert.False(Complete(cache, fill, "$5\r\nstale\r\n")); Assert.Equal(0, cache.Count); Assert.False(TryRead(cache, "abc", out _)); } @@ -200,7 +215,7 @@ public void InvalidationBeforeTheFillStartsDoesNotBlockIt() // the invalidation preceded this request, so its reply reflects the write and is cacheable var frame = Get("abc"); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); - Assert.True(cache.TryComplete(fill, Utf8("$5\r\nfresh\r\n"))); + Assert.True(Complete(cache, fill, "$5\r\nfresh\r\n")); Assert.True(TryRead(cache, "abc", out var text)); Assert.Equal("$5|fresh|", text); } @@ -217,7 +232,7 @@ public void ThreeKeyCommandsCacheAndInvalidateOnAnyKey(int which) Assert.True(frame.KeysNeedScan); // beyond the two inline offsets: resolved from the bitmap Assert.Equal(3, frame.KeyCount); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); - Assert.True(cache.TryComplete(fill, Utf8("*3\r\n$1\r\n1\r\n$1\r\n2\r\n$1\r\n3\r\n"))); + Assert.True(Complete(cache, fill, "*3\r\n$1\r\n1\r\n$1\r\n2\r\n$1\r\n3\r\n")); Assert.True(ThreeKeyHit(cache)); Assert.True(cache.OnInvalidate(Utf8(((char)('a' + which)).ToString()))); @@ -293,12 +308,21 @@ private sealed class FakeExecutor(string response, Action? onSend = null) : IRes public int Database => 0; - public byte[] Send(ReadOnlySpan request) + /// Requests this executor retained, as a resending backlog would. + public List Parked { get; } = []; + + public bool ParkRequests { get; set; } + + public RespPayload Send(in RespRequest request) { Sent++; + if (ParkRequests && request.TryRetain(out var retained)) Parked.Add(retained); onSend?.Invoke(); - return Utf8(response); + return RespPayload.Create(Utf8(response)); } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); } /// The ResultProcessor half: reply bytes in, result out. @@ -325,6 +349,63 @@ public void SendRunsOnceThenServesFromCache() Assert.Equal(1, executor.Sent); } + [Fact] + public async Task SendAsyncMatchesSyncAndHitsCompleteSynchronously() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + + var miss = Get("abc"); + Assert.Equal("$5|hello|", await executor.SendAsync(ref miss, TextHandler.Instance, cache)); + + var hit = Get("abc"); + var pending = executor.SendAsync(ref hit, TextHandler.Instance, cache); + + // a hit never touches the executor, so it must not build a state machine or a Task either + Assert.True(pending.IsCompletedSuccessfully); + Assert.Equal("$5|hello|", await pending); + Assert.Equal(1, executor.Sent); + } + + [Fact] + public void ExecutorCanRetainTheRequestForAResend() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n") { ParkRequests = true }; + + var frame = Get("abc"); + executor.Send(ref frame, TextHandler.Instance, cache); + + // this is why the request is not a span: a backlog must be able to hold it past the call, and + // still read it afterwards to resend + var parked = Assert.Single(executor.Parked); + Assert.Equal("*2|$3|GET|$3|abc|", Text(parked.Span)); + parked.Dispose(); + } + + [Fact] + public void CachedReplyIsSharedWithTheCallerNotCopied() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + + var frame = Get("abc"); + executor.Send(ref frame, TextHandler.Instance, cache); + + using var probe = Get("abc"); + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); + try + { + // the reply the executor produced IS the cached one - TryComplete retains it rather than + // copying it into a second pooled buffer + Assert.Equal("$5|hello|", Text(payload.Span)); + } + finally + { + payload.Release(); + } + } + [Fact] public void SendWithoutACacheIsTheSameCallShape() { @@ -428,7 +509,7 @@ public void MultiKeyEntryIsInvalidatedByAnyOfItsKeys() var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}"); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); - Assert.True(cache.TryComplete(fill, Utf8("*2\r\n$1\r\n1\r\n$1\r\n2\r\n"))); + Assert.True(Complete(cache, fill, "*2\r\n$1\r\n1\r\n$1\r\n2\r\n")); using (var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) { From a0f1b690f2ad003a6005d06c7c60004162bd6c12 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 20:50:38 +0100 Subject: [PATCH 048/360] Spike: render existing Messages into the new frame pipeline Answers whether Message can feed the interpolated/cache work without rewriting the command surface. It can, and the mapping is closer than 'hijack' suggests: RedisDatabase already builds a Message plus a ResultProcessor, which are the same two halves as the new API - a request that renders itself, and something that turns a reply into a result. Most of what the new pipeline needs is already there: Message.GetHashSlot means nothing has to be folded during the write, the argument count is in the header MessageWriter emits, and key prefixes, channel prefix and command map are already applied. The one thing rendered bytes cannot supply is WHICH arguments were keys - the same finding that closed the key-marks seam. MessageWriter happens to have kept that distinction at the call site, Write(in RedisKey) being separate from WriteBulkString(in RedisValue), so the entire integration is one hook plus an IBufferWriter that accumulates and packs the marks. MessageWriter is a readonly ref struct and cannot hold the marks itself, so the recorder is the target writer, resolved once per message in the constructor; the per-key cost is a null check on an already-loaded field. Cost measured by A/B rather than asserted: 66.96 ns with the hook, 68.77 ns without, on SET key value. The hooked build measured faster, which shows the difference is run-to-run variance - so the cost is bounded below the ~3% noise floor, not demonstrably zero. 7 tests, including both routes rendering byte-identically (a correctness property, since the frame is the cache key) and a Message-rendered frame being cached and then found by an interpolated render. One test premise of mine was wrong and is now correct: a standalone ServerSelectionStrategy legitimately reports NoSlot, so the test pins the plumbing - Message's slot reaching the frame - rather than the hashing. --- design/interpolated-resp-writer.md | 40 +++++ .../Interpolated/RespFrameWriter.cs | 166 ++++++++++++++++++ src/StackExchange.Redis/MessageWriter.cs | 9 + .../PublicAPI/PublicAPI.Unshipped.txt | 8 + .../MessageToRespFrameTests.cs | 140 +++++++++++++++ 5 files changed, 363 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespFrameWriter.cs create mode 100644 tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index bace95255..d49f514ef 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1325,6 +1325,46 @@ Commands that return key names, and so need this: `RANDOMKEY`, `KEYS`, `SCAN`, t pops (`BLPOP`/`BRPOP`/`LMPOP`/`ZMPOP`/`BZPOPMIN`/`BZPOPMAX`), `XREAD`/`XREADGROUP` stream names, keyspace notifications, and script/`Execute` results. +#### 6.8 Transition: reusing `Message` rather than rewriting the command surface + +`RedisDatabase` builds a `Message` and pairs it with a `ResultProcessor`. Those are **the same two +halves as the new API** — a request that renders itself, and something that turns a reply into a result — +so the existing command surface can feed the new pipeline without being rewritten. `RespFrameWriter` is a +working demonstration (`MessageToRespFrameTests`). + +| New API | Existing equivalent | +| --- | --- | +| the rendered request | `Message` + `MessageWriter` | +| `IRespHandler.Parse` | `ResultProcessor.SetResultCore(..., ref RespReader)` | +| cluster slot | `Message.GetHashSlot` — already computed, so **nothing to fold during the write** | +| argument count | already in the `*N\r\n` header the writer emits | +| key prefixes, channel prefix, command map | already applied by `MessageWriter` | + +**What bytes cannot supply is which arguments were keys**, which is why this is a writer and not a post-pass +over a rendered frame — §5.2's finding applies directly. The saving grace is that `MessageWriter` kept the +distinction at the call site: `Write(in RedisKey)` is a separate overload from `WriteBulkString(in +RedisValue)`. So the whole integration is **one hook** — `Write(in RedisKey)` reports the current offset — +plus an `IBufferWriter` that accumulates and packs the marks. + +Notes from building it: + +- `MessageWriter` is a `readonly ref struct`, so it cannot accumulate marks itself. The recorder is a + reference to the target writer, resolved **once per message** in the constructor (`writer as + RespFrameWriter`), so the per-key cost is a null check on an already-loaded field. +- **Cost: below the noise floor.** A/B on `SET key value`: 66.96 ns with the hook, 68.77 ns without — the + hooked build measured *faster*, which is proof the difference is run-to-run variance rather than signal. + So the cost is bounded below ~3%, not that it is zero. +- Offsets suffice for ≤2 keys; beyond that the frame's encoding is argument *indices*, which the recorder + derives by walking the finished frame once — off any hot path, and the same walk `TryGetKeys` does in + reverse. +- Both routes render **byte-identically**, pinned by a test. That is a correctness property, not tidiness: + the frame is the cache key, so two routes that disagreed would cache the same logical command twice. + +Still open for a real transition: a cacheability predicate (Redis excludes `FT.*`, probabilistic and +time-series types, and non-deterministic commands such as `HRANDFIELD`/`ZRANDMEMBER`/`HSCAN`), and running +a `ResultProcessor` against a cached payload — it takes `ref RespReader`, which `RespPayload.GetReader()` +supplies, but it also wants a `PhysicalConnection` and `Message` for error context. + --- ## 9. The spike in this repo diff --git a/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs b/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs new file mode 100644 index 000000000..61ff67ae0 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs @@ -0,0 +1,166 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. An that turns what MessageWriter already + /// writes into a - so the existing Message surface can feed the new + /// pipeline without rewriting any of it. + /// + /// + /// + /// The transition plan this exists for: RedisDatabase builds a Message and hands it a + /// ResultProcessor<T>. Those are the same two halves as the new API - a request that renders + /// itself, and something that turns a reply into a result - so the existing several-thousand-method + /// command surface can be pointed at the new cache without being touched. + /// + /// + /// The one thing bytes cannot supply is which arguments were keys, which is why this is a writer + /// and not a post-pass over the rendered frame. A rendered frame is just N bulk strings; key-ness is + /// writer-side semantics. MessageWriter happens to have kept that distinction - Write(in + /// RedisKey) is a separate overload from WriteBulkString(in RedisValue) - so it can report + /// keys as it writes them, and that is the entire integration. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class RespFrameWriter : IBufferWriter + { + private byte[] _buffer; + private int _offset; + private int[] _keyOffsets = new int[4]; + private int _keyCount; + + /// Create a writer with an initial capacity. + /// Initial buffer size hint. + public RespFrameWriter(int capacity = 256) => _buffer = ArrayPool.Shared.Rent(Math.Max(16, capacity)); + + /// Begin again, for reuse across messages. + public void Reset() + { + _offset = 0; + _keyCount = 0; + } + + /// Record that a key starts at the current write position. + /// + /// Called by MessageWriter immediately before it writes a key. This is the only hook the + /// integration needs, and it carries the one fact the bytes do not. + /// + internal void MarkKey() + { + if (_keyCount == _keyOffsets.Length) Array.Resize(ref _keyOffsets, _keyCount * 2); + _keyOffsets[_keyCount++] = _offset; + } + + /// + public void Advance(int count) => _offset += count; + + /// + public Memory GetMemory(int sizeHint = 0) + { + Ensure(sizeHint); + return _buffer.AsMemory(_offset); + } + + /// + public Span GetSpan(int sizeHint = 0) + { + Ensure(sizeHint); + return _buffer.AsSpan(_offset); + } + + /// The bytes written so far. + public ReadOnlySpan Span => _buffer.AsSpan(0, _offset); + + /// + /// Take the rendered bytes as a , transferring the buffer; the writer rents a + /// fresh one for its next message. + /// + /// + /// The cluster slot, which Message.GetHashSlot already computes - so unlike the interpolated + /// writer there is no need to fold it during the write. + /// + public RespFrame Complete(int slot = ServerSelectionStrategy.NoSlot) + { + var buffer = _buffer; + var length = _offset; + var frame = new RespFrame(buffer, 0, length, ReadArgCount(buffer, length), slot, PackKeyMarks(buffer, length)); + + _buffer = ArrayPool.Shared.Rent(Math.Max(16, length)); + Reset(); + return frame; + } + + // the header MessageWriter already wrote says how many arguments there are + private static int ReadArgCount(byte[] buffer, int length) + { + if (length < 2 || buffer[0] != (byte)'*') return 0; + var count = 0; + for (var i = 1; i < length && buffer[i] != (byte)'\r'; i++) + { + count = (count * 10) + (buffer[i] - (byte)'0'); + } + + return count; + } + + /// Pack recorded key offsets into the frame's single 64-bit field. + /// + /// Two keys or fewer keep their byte offsets and need no scan. Beyond that the frame's encoding is a + /// bitmap of ARGUMENT indices, which offsets are not - so derive them by walking the frame once, + /// here, off any hot path. That is the same walk does in reverse. + /// + private ulong PackKeyMarks(byte[] buffer, int length) + { + if (_keyCount == 0) return 0; + if (_keyCount <= 2) + { + var a = (ulong)_keyOffsets[0] & RespFrame.SlotMask; + var b = _keyCount == 2 ? ((ulong)_keyOffsets[1] & RespFrame.SlotMask) << RespFrame.SlotBits : 0; + return a | b; + } + + ulong bitmap = 0; + var i = 0; + while (i < length && buffer[i] != (byte)'\n') i++; // past '*N\r\n' + i++; + + var arg = 0; + while (i < length) + { + if (Array.IndexOf(_keyOffsets, i, 0, _keyCount) >= 0) + { + if (arg <= RespFrame.MaxBitmapArg) bitmap |= 1UL << arg; + else bitmap |= RespFrame.TruncatedFlag; + } + + var j = i + 1; + var len = 0; + while (j < length && buffer[j] != (byte)'\r') + { + len = (len * 10) + (buffer[j] - (byte)'0'); + j++; + } + + i = j + 2 + len + 2; + arg++; + } + + return RespFrame.OverflowFlag | bitmap; + } + + private void Ensure(int sizeHint) + { + if (sizeHint <= 0) sizeHint = 1; + if (_buffer.Length - _offset >= sizeHint) return; + + var bigger = ArrayPool.Shared.Rent(Math.Max(_buffer.Length * 2, _offset + sizeHint)); + Buffer.BlockCopy(_buffer, 0, bigger, 0, _offset); + ArrayPool.Shared.Return(_buffer); + _buffer = bigger; + } + } +} diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs index 82870f1a9..efb40f4be 100644 --- a/src/StackExchange.Redis/MessageWriter.cs +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -15,12 +15,18 @@ internal readonly ref struct MessageWriter private readonly CommandMap _map; private readonly byte[]? _channelPrefix; + // Non-null only when rendering into a RespFrameWriter, which is the transition path that lets the + // existing Message surface feed the interpolated/cache pipeline. Resolved once per writer rather than + // per key, so the cost on the normal path is a null check against an already-loaded field. + private readonly Interpolated.RespFrameWriter? _recorder; + public MessageWriter(byte[]? channelPrefix, CommandMap? map, IBufferWriter writer) { // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract _map = map ?? CommandMap.Default; _channelPrefix = channelPrefix; _writer = writer; + _recorder = writer as Interpolated.RespFrameWriter; } public static IBufferWriter BlockBuffer => BlockBufferSerializer.Shared; @@ -62,6 +68,9 @@ public static void ReleaseBlockBuffer(in ReadOnlySequence request) => public void Write(in RedisKey key) { + // the one fact the rendered bytes cannot carry: that THIS argument is a key + _recorder?.MarkKey(); + var val = key.KeyValue; if (val is string s) { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 278e02075..d6974e27e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -78,6 +78,14 @@ [SER010]StackExchange.Redis.Interpolated.RespFrame.Slot.get -> int [SER010]StackExchange.Redis.Interpolated.RespFrame.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.TryGetKeys(scoped System.Span target) -> int +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Advance(int count) -> void +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Complete(int slot = -1) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.GetMemory(int sizeHint = 0) -> System.Memory +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.GetSpan(int sizeHint = 0) -> System.Span +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Reset() -> void +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.RespFrameWriter(int capacity = 256) -> void +[SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespPayload [SER010]StackExchange.Redis.Interpolated.RespPayload.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.GetReader() -> RESPite.Messages.RespReader diff --git a/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs b/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs new file mode 100644 index 000000000..a3f1404fb --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Transition plan: can the EXISTING Message infrastructure feed the new frame/cache pipeline without +/// rewriting the command surface? These drive real Message objects through MessageWriter into a +/// RespFrameWriter and check the result is indistinguishable from what the interpolated writer produces. +/// +public class MessageToRespFrameTests +{ + private static RespFrame Render(Message message, int slot = ServerSelectionStrategy.NoSlot) + { + var writer = new RespFrameWriter(); + message.WriteTo(new MessageWriter(null, CommandMap.Default, writer)); + return writer.Complete(slot); + } + + private static string Text(ReadOnlySpan value) => + Encoding.UTF8.GetString(value.ToArray()).Replace("\r\n", "|"); + + private static string[] Keys(in RespFrame frame) + { + var count = frame.KeyCount; + Assert.True(count >= 0); + var ranges = new KeyRange[count]; + Assert.Equal(count, frame.TryGetKeys(ranges)); + var keys = new string[count]; + for (var i = 0; i < count; i++) keys[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); + return keys; + } + + [Fact] + public void MessageRendersTheSameBytesAsTheInterpolatedWriter() + { + using var viaMessage = Render(Message.Create(0, CommandFlags.None, RedisCommand.GET, (RedisKey)"mykey")); + + var ctx = new RespContext(); + using var viaInterpolation = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"mykey"}"); + + // byte-identical rendering is not a nicety here: the frame IS the cache key, so two routes that + // disagree would cache the same logical command twice + Assert.Equal(Text(viaInterpolation.Span), Text(viaMessage.Span)); + Assert.Equal("*2|$3|GET|$5|mykey|", Text(viaMessage.Span)); + } + + [Fact] + public void KeysAreRecoveredFromAMessageRender() + { + using var frame = Render(Message.Create(0, CommandFlags.None, RedisCommand.SET, (RedisKey)"k", (RedisValue)"v")); + + Assert.Equal(3, frame.ArgCount); + Assert.Equal(new[] { "k" }, Keys(frame)); // the key, and NOT the value + Assert.False(frame.KeysNeedScan); + } + + [Fact] + public void TwoKeyMessagesUseTheInlineOffsets() + { + using var frame = Render(Message.Create(0, CommandFlags.None, RedisCommand.RENAME, (RedisKey)"src", (RedisKey)"dst")); + + Assert.False(frame.KeysNeedScan); + Assert.Equal(new[] { "src", "dst" }, Keys(frame)); + } + + [Fact] + public void ManyKeyMessagesFallToTheBitmapAndStillResolve() + { + RedisKey[] keys = ["a", "b", "c", "d"]; + using var frame = Render(Message.Create(0, CommandFlags.None, RedisCommand.DEL, keys)); + + // beyond two, offsets do not fit, so RespFrameWriter derives argument indices by walking once + Assert.True(frame.KeysNeedScan); + Assert.Equal(new[] { "a", "b", "c", "d" }, Keys(frame)); + } + + [Fact] + public void InterleavedKeysAndValuesMarkOnlyTheKeys() + { + KeyValuePair[] pairs = + [ + new("k1", "v1"), + new("k2", "v2"), + new("k3", "v3"), + ]; + + using var frame = Render(Message.Create( + 0, CommandFlags.None, RedisCommand.MSET, pairs, Expiration.Default, When.Always)); + + var keys = Keys(frame); + Assert.Equal(new[] { "k1", "k2", "k3" }, keys); + Assert.DoesNotContain("v1", keys); + frame.Dispose(); + } + + [Fact] + public void TheSlotComesFromTheMessageRatherThanBeingFolded() + { + var message = Message.Create(0, CommandFlags.None, RedisCommand.GET, (RedisKey)"{tag}:x"); + + // a standalone strategy legitimately reports NoSlot - routing is a cluster concern - so this pins + // the plumbing rather than the hashing: whatever Message computes is what the frame carries + var standalone = new ServerSelectionStrategy(null!); + Assert.Equal(ServerSelectionStrategy.NoSlot, message.GetHashSlot(standalone)); + + // and a real slot survives the trip. Message already computes this, so unlike the interpolated + // writer there is nothing to fold during the write - one less thing to reimplement + var slot = ServerSelectionStrategy.GetHashSlot((RedisKey)"{tag}:x"); + Assert.NotEqual(ServerSelectionStrategy.NoSlot, slot); + + using var frame = Render(message, slot); + Assert.Equal(slot, frame.Slot); + } + + [Fact] + public void AMessageRenderCanBeCachedAndInvalidated() + { + using var cache = new RespClientCache(); + var frame = Render(Message.Create(0, CommandFlags.None, RedisCommand.GET, (RedisKey)"mykey")); + + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + Assert.True(cache.TryComplete(fill, payload)); + payload.Release(); + + // and a render from the OTHER route finds it - the two are interchangeable as cache keys + var ctx = new RespContext(); + using var probe = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"mykey"}"); + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var hit)); + Assert.Equal("$5|hello|", Text(hit.Span)); + hit.Release(); + + Assert.True(cache.OnInvalidate(Encoding.UTF8.GetBytes("mykey"))); + Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _)); + } +} From 27595260456fa39dd5885e775199a4153e053eac Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 21:08:33 +0100 Subject: [PATCH 049/360] Cacheability gates on the retry category, and never caches keyless commands Cacheability cannot be a command-name list: FT.* is not in this library at all, it is NRedisStack reaching the server through Execute, so a name-based rule is unenforceable for exactly the commands most likely to be wrong. The flags already model it. The retry category is a 5-bit severity ladder and Message.UserSelectableFlags already includes it, so an external surface can declare a category today with no new API. The gate is 'declared, and no more severe than CommandRetryReadOnly'. Both halves matter: zero means nobody declared one, and zero sorts BELOW read-only on the ladder, so a naive <= test would read 'nobody said' as 'safe to cache' - backwards, and precisely the case that matters for commands this library does not define. A test pins it, since it is the one that fails open if written carelessly. flags is consequently NOT optional on Send/SendAsync. Every IDatabase method in this library already carries flags, and whether a command may be cached is a property of the command. Read-only remains necessary but NOT sufficient - non-deterministic commands (SRANDMEMBER, HRANDFIELD, ZRANDMEMBER), cursor-based ones (SCAN, HSCAN) and anything the server does not track for invalidation are all read-only. A second axis is still needed; recorded rather than guessed at. Separately, a bug found while thinking this through: AllValid over an empty dependency list is vacuously true, so a KEYLESS command was cached for the life of the process. Not even a flush cleared it, because OnFlush stamps key nodes and there were none. Server-assisted invalidation only ever reports keys, so a keyless command can never be invalidated - TIME, PING, RANDOMKEY, INFO would all have been permanently stale. Now refused, which also removes part of the non-deterministic problem for free since several of those are keyless. --- design/interpolated-resp-writer.md | 37 ++++++++ .../Interpolated/RespClientCache.cs | 57 +++++++++++- .../Interpolated/RespExecutor.cs | 21 ++++- .../PublicAPI/PublicAPI.Unshipped.txt | 25 +++--- .../RespClientCacheTests.cs | 87 ++++++++++++++++--- 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index d49f514ef..152da34c2 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1365,6 +1365,43 @@ time-series types, and non-deterministic commands such as `HRANDFIELD`/`ZRANDMEM a `ResultProcessor` against a cached payload — it takes `ref RespReader`, which `RespPayload.GetReader()` supplies, but it also wants a `PhysicalConnection` and `Message` for error context. +#### 6.9 Cacheability: gate on the retry category, fail closed + +Cacheability cannot be a list of command names. `FT.*` is not in this library at all — it lives in +NRedisStack, reaching the server through `Execute`/`ExecuteAsync` — so any rule expressed as "these +commands are excluded" is unenforceable for exactly the commands most likely to be wrong. + +The flags already model this. The retry category is a 5-bit severity ladder in `CommandFlags` +(`Message.MaskRetryCategory`, bits 13–17), and `Message.UserSelectableFlags` **already includes it**, so an +external surface can declare a category today with no new API. So the gate is: + +```csharp +var category = flags & Message.MaskRetryCategory; +return category != 0 && category <= CommandFlags.CommandRetryReadOnly; +``` + +**Both halves matter.** Zero means "nobody declared one", and zero sorts *below* `CommandRetryReadOnly` on +the ladder — so a naive `<=` would read "nobody said" as "safe to cache", which is precisely backwards for +commands this library does not define. Undeclared must mean uncacheable. That is pinned by a test, because +it is the one that fails open if written carelessly. + +`flags` is therefore **not optional** on `Send`/`SendAsync`. Every `IDatabase` method in this library +already carries flags; whether a command may be cached is a property of the command, and the caller has to +say. + +**Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. Read-only +commands that must still not be cached: non-deterministic ones (`SRANDMEMBER`, `HRANDFIELD`, +`ZRANDMEMBER`), cursor-based ones (`SCAN`, `HSCAN`), and anything the server does not track for +invalidation — per the Redis docs, the whole `FT.*` family. A second axis is still needed; an explicit +opt-in bit is the obvious shape, and there is room beside `CommandServerSpecific` (bit 18). + +**Keyless commands are never cached.** Found by building this: `AllValid` over an empty dependency list is +vacuously `true`, so a keyless entry was valid *for the life of the process* — not even a flush cleared it, +since `OnFlush` stamps key nodes and there were none. Server-assisted invalidation only ever reports keys, +so a command with no keys can never be invalidated by anything. `TIME`, `PING`, `RANDOMKEY`, `INFO` would +all have been permanently stale. This also removes a slice of the non-deterministic problem for free, since +several of those commands are keyless anyway. + --- ## 9. The spike in this repo diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 637bb1bbb..5f481b109 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -118,12 +118,51 @@ public bool TryGet(in RespRequest frame, int database, [NotNullWhen(true)] out R /// /// public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) + => TryBeginFill(ref frame, database, CommandFlags.CommandRetryReadOnly, out fill); + + /// + /// The rendered request. + /// The database the request runs against. + /// + /// The command's flags. Caching requires a retry category that is set and no more severe than + /// . + /// + /// The fill to complete once the reply arrives. + /// + /// + /// Unset is not cacheable. The retry category region is zero when nobody declared one, and + /// zero compares below on the severity ladder - so a + /// naive <= test would treat "nobody said" as "safe to cache", which is precisely backwards + /// for commands this library does not know. External surfaces such as NRedisStack reach the server + /// through Execute, and Message.UserSelectableFlags already lets them declare a + /// category; declaring nothing must mean no caching. + /// + /// + /// Read-only is necessary but not sufficient, which is why this is a gate rather than the + /// whole test. Plenty of read-only commands must not be cached - non-deterministic ones + /// (SRANDMEMBER, HRANDFIELD, ZRANDMEMBER), cursor-based ones (SCAN, + /// HSCAN), and anything the server does not track for invalidation, which per the Redis docs + /// includes the whole FT.* family. The keyless rule below catches some of these for free; the + /// rest need an explicit opt-in that this spike does not yet model. + /// + /// + public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, out RespFill fill) { + if (!IsCacheableCategory(flags)) + { + fill = default; + return false; + } + var keyCount = frame.KeyCount; - if (keyCount < 0) + if (keyCount <= 0) { + // keyCount < 0: keys not enumerable => not invalidatable. + // keyCount == 0: NOTHING can ever invalidate this. Server-assisted invalidation only ever + // reports keys, so an entry with no dependencies is vacuously valid forever - not even a + // flush clears it, because OnFlush stamps key nodes and there are none. Permanent staleness. fill = default; - return false; // keys not enumerable => not invalidatable => must not be cached + return false; } // the overwhelming majority of commands are well under this; only a huge multi-key command @@ -241,6 +280,20 @@ public void Dispose() _keys.InvalidateAll(); } + /// + /// Whether the command's retry category permits caching at all. + /// + /// + /// The category is a 5-bit severity ladder where zero means "nobody declared one". Both halves of + /// this test matter: != 0 rejects the undeclared case, and <= uses the ladder the + /// flags were built to support, so anything at or beyond a write - or server-admin - is out. + /// + internal static bool IsCacheableCategory(CommandFlags flags) + { + var category = flags & Message.MaskRetryCategory; + return category != 0 && category <= CommandFlags.CommandRetryReadOnly; + } + /// One key a cached entry depends on, and the generation it had when the request was sent. internal readonly struct Dependency(RespKeyTable.Node node, long generation) { diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 7da6aee36..587d95301 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -77,8 +77,20 @@ public static class RespExecutor /// The executor to send through. /// The rendered request; consumed by this call on every path. /// Turns the reply into a result. + /// + /// The command's flags. Caching additionally requires a declared retry category no more severe than + /// ; see + /// . + /// /// The cache to consult, or null to bypass caching entirely. /// + /// + /// is deliberately not optional. Every IDatabase method in + /// this library already carries flags, and whether a command may be cached is a property of the + /// command, not of the call site's enthusiasm - so the caller has to say. Saying nothing + /// () means no caching, which is the safe reading for any command + /// this library does not itself define. + /// /// Three lifetimes are handled here so that no caller has to: the key generations are captured /// before the send; the payload is retained across /// and released in a finally; and the request is consumed on every path. @@ -87,6 +99,7 @@ public static TResult Send( this IRespExecutor executor, ref RespFrame request, IRespHandler handler, + CommandFlags flags, RespClientCache? cache = null) { if (executor is null) throw new ArgumentNullException(nameof(executor)); @@ -96,7 +109,7 @@ public static TResult Send( { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; - if (cache.TryBeginFill(ref request, executor.Database, out var fill)) + if (cache.TryBeginFill(ref request, executor.Database, flags, out var fill)) { // generations captured above, BEFORE this send var filled = executor.Send(fill.Key); @@ -134,10 +147,11 @@ public static TResult Send( } } - /// + /// /// The executor to send through. /// The rendered request; consumed by this call on every path. /// Turns the reply into a result. + /// The command's flags; see the synchronous overload. /// The cache to consult, or null to bypass caching entirely. /// Cancels the send. /// @@ -151,6 +165,7 @@ public static ValueTask SendAsync( this IRespExecutor executor, ref RespFrame request, IRespHandler handler, + CommandFlags flags, RespClientCache? cache = null, CancellationToken cancellationToken = default) { @@ -164,7 +179,7 @@ public static ValueTask SendAsync( return new ValueTask(cached); } - if (cache.TryBeginFill(ref request, executor.Database, out var fill)) + if (cache.TryBeginFill(ref request, executor.Database, flags, out var fill)) { return AwaitFill(executor, fill, handler, cache, cancellationToken); } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index d6974e27e..114c82924 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,22 +1,19 @@ -#nullable enable -[SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool -[SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int -[SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! +#nullable enable [SER010]StackExchange.Redis.Interpolated.IRespExecutor [SER010]StackExchange.Redis.Interpolated.IRespExecutor.Database.get -> int -[SER010]StackExchange.Redis.Interpolated.IRespExecutor.SendAsync(StackExchange.Redis.Interpolated.RespRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(in StackExchange.Redis.Interpolated.RespRequest request) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]StackExchange.Redis.Interpolated.IRespExecutor.SendAsync(StackExchange.Redis.Interpolated.RespRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.KeyRange -[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void +[SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.Length.get -> int [SER010]StackExchange.Redis.Interpolated.KeyRange.Offset.get -> int [SER010]StackExchange.Redis.Interpolated.RespAttribute -[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token, params string![]! additionalTokens) -> void -[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void +[SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token, params string![]! additionalTokens) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.Tokens.get -> string![]! [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int @@ -29,6 +26,7 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.Sweep() -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, StackExchange.Redis.CommandFlags flags, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, StackExchange.Redis.Interpolated.RespPayload! response) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool @@ -40,9 +38,9 @@ [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Complete() -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Dispose() -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.CancellationToken.get -> System.Threading.CancellationToken [SER010]StackExchange.Redis.Interpolated.RespContext.ChannelPrefix.get -> StackExchange.Redis.RedisChannel @@ -101,8 +99,11 @@ [SER010]StackExchange.Redis.Interpolated.RespRequest.RespRequest() -> void [SER010]StackExchange.Redis.Interpolated.RespRequest.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespRequest.TryRetain(out StackExchange.Redis.Interpolated.RespRequest retained) -> bool -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult +[SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool +[SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int +[SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 7d932a471..11ad08ca3 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -343,7 +343,7 @@ public void SendRunsOnceThenServesFromCache() { // note: no 'using' on the frame and none on any payload - Send owns both var frame = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, cache)); + Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); } Assert.Equal(1, executor.Sent); @@ -356,10 +356,10 @@ public async Task SendAsyncMatchesSyncAndHitsCompleteSynchronously() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - Assert.Equal("$5|hello|", await executor.SendAsync(ref miss, TextHandler.Instance, cache)); + Assert.Equal("$5|hello|", await executor.SendAsync(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); var hit = Get("abc"); - var pending = executor.SendAsync(ref hit, TextHandler.Instance, cache); + var pending = executor.SendAsync(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); // a hit never touches the executor, so it must not build a state machine or a Task either Assert.True(pending.IsCompletedSuccessfully); @@ -374,7 +374,7 @@ public void ExecutorCanRetainTheRequestForAResend() var executor = new FakeExecutor("$5\r\nhello\r\n") { ParkRequests = true }; var frame = Get("abc"); - executor.Send(ref frame, TextHandler.Instance, cache); + executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); // this is why the request is not a span: a backlog must be able to hold it past the call, and // still read it afterwards to resend @@ -390,7 +390,7 @@ public void CachedReplyIsSharedWithTheCallerNotCopied() var executor = new FakeExecutor("$5\r\nhello\r\n"); var frame = Get("abc"); - executor.Send(ref frame, TextHandler.Instance, cache); + executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); using var probe = Get("abc"); Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); @@ -412,11 +412,11 @@ public void SendWithoutACacheIsTheSameCallShape() var executor = new FakeExecutor("$5\r\nhello\r\n"); var a = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref a, TextHandler.Instance)); + Assert.Equal("$5|hello|", executor.Send(ref a, TextHandler.Instance, CommandFlags.None)); // a null cache takes the same overload, so enabling caching is one argument, not a rewrite var b = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref b, TextHandler.Instance, cache: null)); + Assert.Equal("$5|hello|", executor.Send(ref b, TextHandler.Instance, CommandFlags.None, cache: null)); Assert.Equal(2, executor.Sent); // no caching either way } @@ -431,7 +431,7 @@ public void SendStillAnswersWhenInvalidatedInFlight() var executor = new FakeExecutor("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); var frame = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, cache)); // still answered + Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); // still answered Assert.Equal(0, cache.Count); // ... not cached } @@ -444,7 +444,7 @@ public void SendAnswersEvenWhenTheFrameCannotBeCached() var frame = writer.Complete(); var executor = new FakeExecutor("$2\r\nok\r\n"); - Assert.Equal("$2|ok|", executor.Send(ref frame, TextHandler.Instance, cache)); + Assert.Equal("$2|ok|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); Assert.Equal(0, cache.Count); // this path FALLS THROUGH to the uncached tail rather than duplicating it, so the frame must be @@ -459,10 +459,10 @@ public void SendLeavesNoReferenceBehindOnAnyPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - executor.Send(ref fill, TextHandler.Instance, cache); + executor.Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); var hit = Get("abc"); - executor.Send(ref hit, TextHandler.Instance, cache); + executor.Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); // exactly one reference survives - the cache entry's. If the helper leaked the caller's retain the // buffer would never return to the pool; if it over-released, the entry would be reading freed bytes @@ -480,18 +480,77 @@ public void SendConsumesTheFrameOnEveryPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - executor.Send(ref miss, TextHandler.Instance, cache); + executor.Send(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); Assert.Throws(() => miss.AsLookupKey()); var hit = Get("abc"); - executor.Send(ref hit, TextHandler.Instance, cache); + executor.Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); Assert.Throws(() => hit.AsLookupKey()); var uncached = Get("abc"); - executor.Send(ref uncached, TextHandler.Instance); // the no-cache overload too + executor.Send(ref uncached, TextHandler.Instance, CommandFlags.None); // the no-cache overload too Assert.Throws(() => uncached.AsLookupKey()); } + [Fact] + public void KeylessCommandsAreNeverCached() + { + using var cache = new RespClientCache(); + + // a keyless command can NEVER be invalidated: server-assisted invalidation only ever reports keys, + // so an entry with no dependencies is vacuously valid forever. Not even a FLUSHALL clears it, + // because OnFlush stamps key nodes and this entry has none. Permanent staleness - refuse it. + var frame = Ctx.Execute($"{RedisCommand.TIME}"); + Assert.Equal(0, frame.KeyCount); + Assert.False(cache.TryBeginFill(ref frame, 0, out _)); + frame.Dispose(); + + Assert.Equal(0, cache.Count); + } + + [Theory] + // cacheable: a declared category no more severe than read-only + [InlineData(CommandFlags.CommandRetryAlways, true)] + [InlineData(CommandFlags.CommandRetryConnection, true)] + [InlineData(CommandFlags.CommandRetryReadOnly, true)] + // not cacheable: writes and above + [InlineData(CommandFlags.CommandRetryWriteChecked, false)] + [InlineData(CommandFlags.CommandRetryWriteLastWins, false)] + [InlineData(CommandFlags.CommandRetryWriteAccumulating, false)] + [InlineData(CommandFlags.CommandRetryServerAdmin, false)] + [InlineData(CommandFlags.CommandRetryNever, false)] + // and the trap: nobody declared one. Zero sits BELOW read-only on the ladder, so a naive <= test + // would read "nobody said" as "safe to cache" - backwards, and exactly the case that matters for + // commands this library does not know, such as NRedisStack's FT.* + [InlineData(CommandFlags.None, false)] + public void CachingDemandsADeclaredReadOnlyCategory(CommandFlags flags, bool cacheable) + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + Assert.Equal(cacheable, cache.TryBeginFill(ref frame, 0, flags, out var fill)); + + if (cacheable) + { + Assert.True(Complete(cache, fill, "$5\r\nhello\r\n")); + } + else + { + frame.Dispose(); + } + } + + [Fact] + public void UnsetCategoryIsRefusedEvenThoughItComparesBelowReadOnly() + { + // pinning the arithmetic directly, because this is the one that fails open if written naively + Assert.True(RespClientCache.IsCacheableCategory(CommandFlags.CommandRetryReadOnly)); + Assert.False(RespClientCache.IsCacheableCategory(CommandFlags.None)); + Assert.True((CommandFlags.None & Message.MaskRetryCategory) < CommandFlags.CommandRetryReadOnly); + + // flags unrelated to the category must not accidentally satisfy the gate + Assert.False(RespClientCache.IsCacheableCategory(CommandFlags.PreferReplica | CommandFlags.FireAndForget)); + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From bcea13ef5597cae8d533a39deaebee6dd1809a8d Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 21:18:54 +0100 Subject: [PATCH 050/360] Client-side caching is opt-out: CommandFlags.NoClientCache Caching is on by default for anything that clears the gates (declared retry category no more severe than read-only, and at least one key); NoClientCache (bit 19) suppresses it. Opt-in was rejected: it would mean touching every IDatabase method, and one omission makes the feature silently do nothing. The case that argued for opt-in turned out to be close to empty. FT.* takes an index name rather than a keyspace key, so it is keyless and already refused - my counter-example was simply wrong. Probabilistic and time-series types are keyed on real keys, so tracking works and the Redis docs exclude them for efficiency, not correctness, which is exactly what an opt-out is for. What remains needs a third party to write a module, enable caching, AND positively declare a read-only retry category on something the server does not track; doing nothing is already safe, since an undeclared category is uncacheable. NoClientCache suppresses the PROBE as well as the store - opting out has to mean you do not receive a cached answer either, not merely that this reply is not kept. It is in UserSelectableFlags so external surfaces can reach it. Not a new rung on the retry ladder, which was considered: - WithCategory says 'if the user has already specified a category, that wins', so opting out via the category would REPLACE the retry category and silently change reconnect behaviour. - A rung above ReadOnly reads as more severe, so retry policies testing <= ReadOnly would stop retrying it: a caching annotation causing a retry regression. Below avoids that but forces recategorising every read-only command. - The codebase already made this call: CommandServerSpecific sits outside the ladder as 'an orthogonal flag, not part of the <=-comparable severity ladder'. Four cold-path counters (Stored, RefusedByFlags, RefusedNoKeys, RefusedRaced), incremented only on a miss so a hit costs nothing, because the remaining failure mode is silent and durable. Known non-deterministic and cursor commands stay a job for command metadata, not flags: a compile-time property of our own enum should not be pushed onto every call site. --- design/interpolated-resp-writer.md | 56 ++++++++++++- src/StackExchange.Redis/Enums/CommandFlags.cs | 19 +++++ .../Interpolated/RespClientCache.cs | 39 ++++++++- .../Interpolated/RespExecutor.cs | 6 +- src/StackExchange.Redis/Message.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 5 ++ .../RespClientCacheTests.cs | 83 ++++++++++++++++++- 7 files changed, 199 insertions(+), 10 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 152da34c2..7111e78ef 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1391,9 +1391,59 @@ say. **Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. Read-only commands that must still not be cached: non-deterministic ones (`SRANDMEMBER`, `HRANDFIELD`, -`ZRANDMEMBER`), cursor-based ones (`SCAN`, `HSCAN`), and anything the server does not track for -invalidation — per the Redis docs, the whole `FT.*` family. A second axis is still needed; an explicit -opt-in bit is the obvious shape, and there is room beside `CommandServerSpecific` (bit 18). +`ZRANDMEMBER`) and cursor-based ones (`SCAN`, `HSCAN`). Those are *our* commands, so they belong in +command metadata rather than in flags — a compile-time property of our own enum should not be pushed onto +every call site. + +##### Opt-out, not opt-in + +The caller-facing control is **`CommandFlags.NoClientCache`** (bit 19), and caching is otherwise on by +default for anything that clears the gates. Opt-in was considered and rejected: it would mean touching +every `IDatabase` method, and a single omission makes the feature silently do nothing. + +The worry that argued for opt-in was an external command that is read-only, keyed, and *not* tracked by +the server — it would be cached and never invalidated. On inspection that population is close to empty: + +- `FT.*` takes an **index name, not a keyspace key**, so it is keyless and the rule above already refuses + it. (This was my counter-example, and it was simply wrong.) +- Probabilistic and time-series types (`BF.*`, `TS.*`) are keyed on *real* keyspace keys, so tracking and + invalidation work normally. The Redis docs exclude them because *"these types are designed to be updated + frequently, which means caching has little or no benefit"* — an efficiency argument, not a correctness + one, and precisely what an opt-out is for. + +What remains is a third party who writes their own module, enables client-side caching, declares a +read-only retry category, and whose module reads are not registered for invalidation by the server. Note +that doing *nothing* is already safe: an undeclared category is uncacheable, so the failure needs a +positive act of mis-declaration. And caching is globally opt-in in the first place. Treating that as caller +error is consistent with how this same enum already treats retry categories, where mis-declaring gets you +duplicate writes on a reconnect — a worse outcome that we already trust callers to avoid. + +`NoClientCache` suppresses the **probe as well as the store**: opting out has to mean the caller does not +receive a cached answer either, not merely that this reply is not kept. + +##### Why not a new rung on the retry ladder + +Tempting — it is a numeric range with gaps — but no: + +- **The caller wins on the ladder.** `WithCategory` is explicit: *"if the user has already specified a + category, that wins."* So opting out of caching via the category would *replace* the retry category, and + a caller suppressing caching on a churny value would silently change reconnect behaviour. +- **Inserting above `ReadOnly` breaks every `<=`.** A "read-only but uncacheable" rung reads as more + severe, so retry policies testing `<= CommandRetryReadOnly` would stop retrying it: a caching annotation + causing a retry regression. Inserting *below* avoids that but forces recategorising every read-only + command and leaves `ReadOnly` meaning "not cacheable". +- **The codebase already decided this.** `CommandServerSpecific` sits outside the ladder because it is + *"an orthogonal flag, not part of the `<=`-comparable severity ladder"*. Same shape, same answer. The + ladder orders one axis — is it safe to send again; cacheability asks another — will invalidation tell me + when this changes. + +##### Diagnosability + +The failure this design can still produce is silent and durable: something wrongly cached serves stale data +forever, with no error and no log. So the fill path keeps four counters — `Stored`, `RefusedByFlags`, +`RefusedNoKeys`, `RefusedRaced`. They are incremented only on a miss, which has already paid for a round +trip, so a cache hit costs nothing. "Why is this stale?" and "why is nothing being cached?" should both be +answerable without a debugger. **Keyless commands are never cached.** Found by building this: `AllValid` over an empty dependency list is vacuously `true`, so a keyless entry was valid *for the life of the process* — not even a flush cleared it, diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index b1b99b672..c3ba98087 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -102,6 +102,25 @@ public enum CommandFlags /// NoScriptCache = 512, + /// + /// Indicates that this command must not be served from, or stored in, the client-side cache. + /// + /// + /// + /// Client-side caching is opt-out rather than opt-in: a command that declares a retry category no + /// more severe than and names at least one key is cacheable by + /// default. This suppresses that. + /// + /// + /// Two reasons to reach for it. A value that changes so often that invalidation traffic costs more + /// than the cache saves - probabilistic and time-series types are the documented examples, and the + /// Redis guidance is to keep such data off a caching connection entirely. And a custom module + /// command whose reads the server does not register for invalidation: it would otherwise be cached + /// and never invalidated, and the library cannot know that on your behalf. + /// + /// + NoClientCache = 1 << 19, + // 1024: used for "no flush"; never user-specified, so not visible on the public API // 2048: Use subscription connection type; never user-specified, so not visible on the public API diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 5f481b109..abeaa3433 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; using RESPite; namespace StackExchange.Redis.Interpolated @@ -41,6 +42,10 @@ public sealed class RespClientCache : IDisposable { private readonly ConcurrentDictionary _entries = new(); private readonly RespKeyTable _keys; + private long _stored; + private long _refusedByFlags; + private long _refusedNoKeys; + private long _refusedRaced; /// Create a cache. /// Initial size hint for the tracked-key table. @@ -52,6 +57,30 @@ public sealed class RespClientCache : IDisposable /// The number of distinct keys being tracked. public int TrackedKeyCount => _keys.Count; + /// Fills that were stored. + /// + /// These counters and the ones below are incremented only on the fill path - once per cache miss, + /// which has already paid for a round trip - so they cost nothing on a hit. They exist because the + /// failure mode this design can still produce is silent and durable: a command cached that should + /// not have been serves stale data forever, with no error and no log. "Why is this stale?" and "why + /// is nothing being cached?" should both be answerable without a debugger. + /// + public long Stored => Volatile.Read(ref _stored); + + /// Fills refused because the flags did not permit caching. + /// + /// The usual cause is a command that never declared a retry category - which is uncacheable by + /// design, since undeclared cannot mean "safe". A surprisingly high count here usually means an + /// external command surface is not declaring categories. + /// + public long RefusedByFlags => Volatile.Read(ref _refusedByFlags); + + /// Fills refused because the request named no keys, so nothing could ever invalidate it. + public long RefusedNoKeys => Volatile.Read(ref _refusedNoKeys); + + /// Fills refused because an invalidation landed while the command was in flight. + public long RefusedRaced => Volatile.Read(ref _refusedRaced); + /// /// Invalidate one key, as reported by the server. Allocation-free, and cheap when the key is not /// cached here. @@ -148,8 +177,9 @@ public bool TryBeginFill(ref RespFrame frame, int database, out RespFill fill) /// public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, out RespFill fill) { - if (!IsCacheableCategory(flags)) + if (!IsCacheable(flags)) { + Interlocked.Increment(ref _refusedByFlags); fill = default; return false; } @@ -161,6 +191,7 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, // keyCount == 0: NOTHING can ever invalidate this. Server-assisted invalidation only ever // reports keys, so an entry with no dependencies is vacuously valid forever - not even a // flush clears it, because OnFlush stamps key nodes and there are none. Permanent staleness. + Interlocked.Increment(ref _refusedNoKeys); fill = default; return false; } @@ -211,6 +242,7 @@ public bool TryComplete(in RespFill fill, RespPayload response) if (!Dependency.AllValid(fill.Dependencies)) { + Interlocked.Increment(ref _refusedRaced); fill.Key.Dispose(); return false; } @@ -232,6 +264,7 @@ public bool TryComplete(in RespFill fill, RespPayload response) if (_entries.TryAdd(new EntryKey(stored, fill.Database), new Entry(response, fill.Dependencies))) { fill.Key.Dispose(); // the dictionary holds its own references now + Interlocked.Increment(ref _stored); return true; } @@ -288,8 +321,10 @@ public void Dispose() /// this test matter: != 0 rejects the undeclared case, and <= uses the ladder the /// flags were built to support, so anything at or beyond a write - or server-admin - is out. /// - internal static bool IsCacheableCategory(CommandFlags flags) + internal static bool IsCacheable(CommandFlags flags) { + if ((flags & CommandFlags.NoClientCache) != 0) return false; + var category = flags & Message.MaskRetryCategory; return category != 0 && category <= CommandFlags.CommandRetryReadOnly; } diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 587d95301..590ad5f5c 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -105,7 +105,9 @@ public static TResult Send( if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); - if (cache is not null) + // NoClientCache suppresses the PROBE as well as the store: opting out must mean the caller does + // not get a cached answer either, not merely that this reply is not kept + if (cache is not null && RespClientCache.IsCacheable(flags)) { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; @@ -172,7 +174,7 @@ public static ValueTask SendAsync( if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); - if (cache is not null) + if (cache is not null && RespClientCache.IsCacheable(flags)) { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) { diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 837651106..82e4f7715 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -90,6 +90,7 @@ internal const CommandFlags | CommandFlags.FireAndForget | CommandFlags.NoRedirect | CommandFlags.NoScriptCache + | CommandFlags.NoClientCache | MaskRetryCategory // caller may override the retry category... | CommandServerSpecific // ...and the server-specific flag | NoFlushFlag; // we'll allow this one even though not advertised diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 114c82924..e8e4917c7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.CommandFlags [SER010]StackExchange.Redis.Interpolated.IRespExecutor [SER010]StackExchange.Redis.Interpolated.IRespExecutor.Database.get -> int [SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(in StackExchange.Redis.Interpolated.RespRequest request) -> StackExchange.Redis.Interpolated.RespPayload! @@ -20,10 +21,14 @@ [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedByFlags.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.Abandon() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Stored.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.Sweep() -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, StackExchange.Redis.CommandFlags flags, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 11ad08ca3..c5679e697 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -543,12 +543,89 @@ public void CachingDemandsADeclaredReadOnlyCategory(CommandFlags flags, bool cac public void UnsetCategoryIsRefusedEvenThoughItComparesBelowReadOnly() { // pinning the arithmetic directly, because this is the one that fails open if written naively - Assert.True(RespClientCache.IsCacheableCategory(CommandFlags.CommandRetryReadOnly)); - Assert.False(RespClientCache.IsCacheableCategory(CommandFlags.None)); + Assert.True(RespClientCache.IsCacheable(CommandFlags.CommandRetryReadOnly)); + Assert.False(RespClientCache.IsCacheable(CommandFlags.None)); Assert.True((CommandFlags.None & Message.MaskRetryCategory) < CommandFlags.CommandRetryReadOnly); // flags unrelated to the category must not accidentally satisfy the gate - Assert.False(RespClientCache.IsCacheableCategory(CommandFlags.PreferReplica | CommandFlags.FireAndForget)); + Assert.False(RespClientCache.IsCacheable(CommandFlags.PreferReplica | CommandFlags.FireAndForget)); + } + + [Fact] + public void NoClientCacheSuppressesStoring() + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + + Assert.False(cache.TryBeginFill( + ref frame, 0, CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache, out _)); + frame.Dispose(); + + Assert.Equal(0, cache.Count); + Assert.Equal(1, cache.RefusedByFlags); + } + + [Fact] + public void NoClientCacheAlsoSuppressesServingFromCache() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + + var fill = Get("abc"); + executor.Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Assert.Equal(1, executor.Sent); + + // opting out must mean the caller does not RECEIVE a cached answer either - not merely that this + // reply is not kept. Otherwise "don't cache this" silently still serves stale data. + var opted = Get("abc"); + executor.Send(ref opted, TextHandler.Instance, + CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache, cache); + Assert.Equal(2, executor.Sent); + + // ... and the entry is untouched for callers who did not opt out + var normal = Get("abc"); + executor.Send(ref normal, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Assert.Equal(2, executor.Sent); + } + + [Fact] + public void RefusalCountersSayWhyNothingWasCached() + { + using var cache = new RespClientCache(); + + var undeclared = Get("abc"); + cache.TryBeginFill(ref undeclared, 0, CommandFlags.None, out _); + undeclared.Dispose(); + + var keyless = Ctx.Execute($"{RedisCommand.TIME}"); + cache.TryBeginFill(ref keyless, 0, CommandFlags.CommandRetryReadOnly, out _); + keyless.Dispose(); + + var raced = Get("xyz"); + Assert.True(cache.TryBeginFill(ref raced, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + cache.OnInvalidate(Utf8("xyz")); + Assert.False(Complete(cache, fill, "$1\r\nx\r\n")); + + var good = Get("ok"); + Assert.True(cache.TryBeginFill(ref good, 0, CommandFlags.CommandRetryReadOnly, out var ok)); + Assert.True(Complete(cache, ok, "$1\r\nx\r\n")); + + // the silent failure this design can still produce is a durable one, so each refusal reason is + // separately countable rather than lumped into "it didn't cache" + Assert.Equal(1, cache.RefusedByFlags); + Assert.Equal(1, cache.RefusedNoKeys); + Assert.Equal(1, cache.RefusedRaced); + Assert.Equal(1, cache.Stored); + } + + [Fact] + public void NoClientCacheIsUserSelectable() + { + // an external surface has to be able to pass it through Execute, or the opt-out is unreachable + // for exactly the callers who need it + Assert.Equal( + CommandFlags.NoClientCache, + Message.UserSelectableFlags & CommandFlags.NoClientCache); } private static string[] KeyStrings(in RespFrame frame) From 8db77879993807ab1ee672f73415ca44f4252e2e Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 21:27:39 +0100 Subject: [PATCH 051/360] Capture the cache design reasoning properly in the notes 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. --- design/interpolated-resp-writer.md | 405 +++++++++++++++++------------ 1 file changed, 238 insertions(+), 167 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 7111e78ef..872826428 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -971,7 +971,44 @@ merely documented. Measured: a steady-state cache hit — render, probe, retain, read, release — allocates **zero** bytes. -#### 6.6 Invalidation: two tables, not a cross-index +### 6.5 Exception paths + +The lowering puts construction and all `Append` calls in the *caller's* frame, before `Execute` is +entered: + +```csharp +var h = new Resp(...); // rents here +h.AppendFormatted(cmd); // can throw: disabled command +h.AppendFormatted(key); // can throw: a hole's property getter +Execute(h, handler); // consumer's using/try-finally only starts HERE +``` + +So on a throw in that window there is no handler for the consumer to dispose. Dropping the buffer is +the only available behaviour, and that is **accepted, not merely tolerated**: `DefaultInterpolatedStringHandler` +does exactly the same — it rents from `ArrayPool.Shared` and abandons the rental if an +interpolation throws, because the compiler emits no `try`/`finally` around the append sequence. Broken +usage dumping an incomplete buffer is the established behaviour of the pattern. + +It is also harmless here: `MemoryTrackedPool` is a thin wrapper over `ArrayPool.Shared` +(`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, so a dropped buffer is +simply garbage. + +Two notes: + +- **Validate the command before renting** where it is free to do so. A CommandMap-disabled command is + both the most likely throw here and the most likely to *repeat*, being configuration-driven. The + command-as-argument form (§4.1) gets this for nothing, since `command` reaches the constructor. This is + a tidiness win rather than a correctness one — see the `DefaultInterpolatedStringHandler` precedent + above — so it is not worth contorting the API for. +- **A bounded custom pool would invalidate this.** Dropping into `ArrayPool.Shared` is free because + Shared doesn't track; dropping a chunk from a bounded free-list permanently removes capacity and + silently degrades to allocating every time. `CycleBuffer.AppendOrRecycle(segment, maxDepth: 2)` shows + the bounded pattern is idiomatic here, so this needs care. Keep any dedicated pool unbounded — + allocate on miss, return opportunistically. + +--- + +### 6.6 Invalidation: two tables, not a cross-index Verified against the protocol first: an invalidation message carries **an array of key names and nothing else** — no timestamp, no version, no epoch. A `null` in its place means `FLUSHALL`/`FLUSHDB`. Two further @@ -1018,7 +1055,7 @@ cache at all. Measured (`ClientCacheBenchmarks`): `OnInvalidate` is **~5-6 ns, zero allocation, flat from 1 to 100,000 cached keys** — about 170M invalidations/sec on one thread, for both hits and misses. -#### 6.7 Why `GetOrExecute`, and what kind of cache this is +### 6.7 Sending through the cache, and what kind of cache this is The obvious hand-written shape is **wrong**, and not in a way a careful caller can fix: @@ -1032,16 +1069,17 @@ if (!cache.TryGet(req, out resp)) By the time `Add` runs there is nothing left to compare against, so an invalidation that arrived during `Execute` cannot be detected — and the server will not repeat it, having dropped the key from its table -when it fired. The result is a *permanently* stale entry. That is why `GetOrExecute` exists: it captures -generations before it calls the executor, so the completion can see that the world moved. It is not sugar; -**it is the only shape that is correct by construction**, and the explicit `TryBeginFill`/`TryComplete` -pair is for callers who need to interleave their own dispatch. +when it fired. The result is a *permanently* stale entry. That is why the orchestration has to own the +call: it captures generations before it sends, so the completion can see that the world moved. It is not +sugar; **it is the only shape that is correct by construction**, and the explicit +`TryBeginFill`/`TryComplete` pair is for callers who need to interleave their own dispatch. A response that arrives after an invalidation is still *returned* — it is a legitimate answer for a read that raced a write, and the caller would have got it anyway without a cache — it is simply not stored. **The cache is a participant in the send, not the entry point.** An earlier shape had the cache own the -call (`cache.GetOrExecute(...)`, with the command supplying its own `Execute`). That was backwards twice +call (`cache.GetOrExecute(...)`, with the command supplying its own `Execute`) — since removed. That was +backwards twice over: a cache that calls the executor has to sit above dispatch and know how to send, and a *command* has no business knowing how to send itself. Inverting it gives the shape the library already has — an executor that sends, and a handler that is exactly the `ResultProcessor` role: @@ -1106,7 +1144,7 @@ return executor.Send(ref req, handler, cache); // no using, nothing to release Executor and handler are passed as interfaces, so hold **one instance of each and reuse them** — a `struct` implementation would box per call. Reused instances allocate nothing per request. -**Read-through, and no write path at all.** `GetOrExecute` makes the cache own the fetch, which is +**Read-through, and no write path at all.** `Send` with a cache makes the cache own the fetch, which is read-through; the raw `TryGet` + `TryBeginFill` pair is cache-aside. Neither write-through nor write-behind applies, because **writes never go through this cache**. Coherence comes from the server telling us what changed, which puts this closer to hardware cache coherence than to the application-caching taxonomy: we @@ -1127,43 +1165,174 @@ whole lifetime, so keys can be recovered lazily from a cached entry without re-r would have forced rebasing them by the frame-start delta — the same off-by-a-few-bytes hazard as §5.2, reintroduced at a second site. -### 6.5 Exception paths +### 6.8 Transition: reusing `Message` rather than rewriting the command surface -The lowering puts construction and all `Append` calls in the *caller's* frame, before `Execute` is -entered: +`RedisDatabase` builds a `Message` and pairs it with a `ResultProcessor`. Those are **the same two +halves as the new API** — a request that renders itself, and something that turns a reply into a result — +so the existing command surface can feed the new pipeline without being rewritten. `RespFrameWriter` is a +working demonstration (`MessageToRespFrameTests`). + +| New API | Existing equivalent | +| --- | --- | +| the rendered request | `Message` + `MessageWriter` | +| `IRespHandler.Parse` | `ResultProcessor.SetResultCore(..., ref RespReader)` | +| cluster slot | `Message.GetHashSlot` — already computed, so **nothing to fold during the write** | +| argument count | already in the `*N\r\n` header the writer emits | +| key prefixes, channel prefix, command map | already applied by `MessageWriter` | + +**What bytes cannot supply is which arguments were keys**, which is why this is a writer and not a post-pass +over a rendered frame — §5.2's finding applies directly. The saving grace is that `MessageWriter` kept the +distinction at the call site: `Write(in RedisKey)` is a separate overload from `WriteBulkString(in +RedisValue)`. So the whole integration is **one hook** — `Write(in RedisKey)` reports the current offset — +plus an `IBufferWriter` that accumulates and packs the marks. + +Notes from building it: + +- `MessageWriter` is a `readonly ref struct`, so it cannot accumulate marks itself. The recorder is a + reference to the target writer, resolved **once per message** in the constructor (`writer as + RespFrameWriter`), so the per-key cost is a null check on an already-loaded field. +- **Cost: below the noise floor.** A/B on `SET key value`: 66.96 ns with the hook, 68.77 ns without — the + hooked build measured *faster*, which is proof the difference is run-to-run variance rather than signal. + So the cost is bounded below ~3%, not that it is zero. +- Offsets suffice for ≤2 keys; beyond that the frame's encoding is argument *indices*, which the recorder + derives by walking the finished frame once — off any hot path, and the same walk `TryGetKeys` does in + reverse. +- Both routes render **byte-identically**, pinned by a test. That is a correctness property, not tidiness: + the frame is the cache key, so two routes that disagreed would cache the same logical command twice. + +Still open for a real transition: a cacheability predicate (Redis excludes `FT.*`, probabilistic and +time-series types, and non-deterministic commands such as `HRANDFIELD`/`ZRANDMEMBER`/`HSCAN`), and running +a `ResultProcessor` against a cached payload — it takes `ref RespReader`, which `RespPayload.GetReader()` +supplies, but it also wants a `PhysicalConnection` and `Message` for error context. + +### 6.9 Cacheability: gate on the retry category, fail closed + +Cacheability cannot be a list of command names. `FT.*` is not in this library at all — it lives in +NRedisStack, reaching the server through `Execute`/`ExecuteAsync` — so any rule expressed as "these +commands are excluded" is unenforceable for exactly the commands most likely to be wrong. + +The flags already model this. The retry category is a 5-bit severity ladder in `CommandFlags` +(`Message.MaskRetryCategory`, bits 13–17), and `Message.UserSelectableFlags` **already includes it**, so an +external surface can declare a category today with no new API. So the gate is: ```csharp -var h = new Resp(...); // rents here -h.AppendFormatted(cmd); // can throw: disabled command -h.AppendFormatted(key); // can throw: a hole's property getter -Execute(h, handler); // consumer's using/try-finally only starts HERE +var category = flags & Message.MaskRetryCategory; +return category != 0 && category <= CommandFlags.CommandRetryReadOnly; ``` -So on a throw in that window there is no handler for the consumer to dispose. Dropping the buffer is -the only available behaviour, and that is **accepted, not merely tolerated**: `DefaultInterpolatedStringHandler` -does exactly the same — it rents from `ArrayPool.Shared` and abandons the rental if an -interpolation throws, because the compiler emits no `try`/`finally` around the append sequence. Broken -usage dumping an incomplete buffer is the established behaviour of the pattern. +**Both halves matter.** Zero means "nobody declared one", and zero sorts *below* `CommandRetryReadOnly` on +the ladder — so a naive `<=` would read "nobody said" as "safe to cache", which is precisely backwards for +commands this library does not define. Undeclared must mean uncacheable. That is pinned by a test, because +it is the one that fails open if written carelessly. -It is also harmless here: `MemoryTrackedPool` is a thin wrapper over `ArrayPool.Shared` -(`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, so a dropped buffer is -simply garbage. +`flags` is therefore **not optional** on `Send`/`SendAsync`. Every `IDatabase` method in this library +already carries flags; whether a command may be cached is a property of the command, and the caller has to +say. -Two notes: +**Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. Read-only +commands that must still not be cached: non-deterministic ones (`SRANDMEMBER`, `HRANDFIELD`, +`ZRANDMEMBER`) and cursor-based ones (`SCAN`, `HSCAN`). Those are *our* commands, so they belong in +command metadata rather than in flags — a compile-time property of our own enum should not be pushed onto +every call site. -- **Validate the command before renting** where it is free to do so. A CommandMap-disabled command is - both the most likely throw here and the most likely to *repeat*, being configuration-driven. The - command-as-argument form (§4.1) gets this for nothing, since `command` reaches the constructor. This is - a tidiness win rather than a correctness one — see the `DefaultInterpolatedStringHandler` precedent - above — so it is not worth contorting the API for. -- **A bounded custom pool would invalidate this.** Dropping into `ArrayPool.Shared` is free because - Shared doesn't track; dropping a chunk from a bounded free-list permanently removes capacity and - silently degrades to allocating every time. `CycleBuffer.AppendOrRecycle(segment, maxDepth: 2)` shows - the bounded pattern is idiomatic here, so this needs care. Keep any dedicated pool unbounded — - allocate on miss, return opportunistically. +#### Opt-out, not opt-in + +The caller-facing control is **`CommandFlags.NoClientCache`** (bit 19), and caching is otherwise on by +default for anything that clears the gates. Opt-in was considered and rejected: it would mean touching +every `IDatabase` method, and a single omission makes the feature silently do nothing. + +The worry that argued for opt-in was an external command that is read-only, keyed, and *not* tracked by +the server — it would be cached and never invalidated. On inspection that population is close to empty: + +- `FT.*` takes an **index name, not a keyspace key**, so it is keyless and the rule above already refuses + it. (This was my counter-example, and it was simply wrong.) +- Probabilistic and time-series types (`BF.*`, `TS.*`) are keyed on *real* keyspace keys, so tracking and + invalidation work normally. The Redis docs exclude them because *"these types are designed to be updated + frequently, which means caching has little or no benefit"* — an efficiency argument, not a correctness + one, and precisely what an opt-out is for. + +What remains is a third party who writes their own module, enables client-side caching, declares a +read-only retry category, and whose module reads are not registered for invalidation by the server. Note +that doing *nothing* is already safe: an undeclared category is uncacheable, so the failure needs a +positive act of mis-declaration. And caching is globally opt-in in the first place. Treating that as caller +error is consistent with how this same enum already treats retry categories, where mis-declaring gets you +duplicate writes on a reconnect — a worse outcome that we already trust callers to avoid. + +`NoClientCache` suppresses the **probe as well as the store**: opting out has to mean the caller does not +receive a cached answer either, not merely that this reply is not kept. + +#### Why not a new rung on the retry ladder + +Tempting — it is a numeric range with gaps — but no: + +- **The caller wins on the ladder.** `WithCategory` is explicit: *"if the user has already specified a + category, that wins."* So opting out of caching via the category would *replace* the retry category, and + a caller suppressing caching on a churny value would silently change reconnect behaviour. +- **Inserting above `ReadOnly` breaks every `<=`.** A "read-only but uncacheable" rung reads as more + severe, so retry policies testing `<= CommandRetryReadOnly` would stop retrying it: a caching annotation + causing a retry regression. Inserting *below* avoids that but forces recategorising every read-only + command and leaves `ReadOnly` meaning "not cacheable". +- **The codebase already decided this.** `CommandServerSpecific` sits outside the ladder because it is + *"an orthogonal flag, not part of the `<=`-comparable severity ladder"*. Same shape, same answer. The + ladder orders one axis — is it safe to send again; cacheability asks another — will invalidation tell me + when this changes. + +#### Diagnosability + +The failure this design can still produce is silent and durable: something wrongly cached serves stale data +forever, with no error and no log. So the fill path keeps four counters — `Stored`, `RefusedByFlags`, +`RefusedNoKeys`, `RefusedRaced`. They are incremented only on a miss, which has already paid for a round +trip, so a cache hit costs nothing. "Why is this stale?" and "why is nothing being cached?" should both be +answerable without a debugger. + +**Keyless commands are never cached.** Found by building this: `AllValid` over an empty dependency list is +vacuously `true`, so a keyless entry was valid *for the life of the process* — not even a flush cleared it, +since `OnFlush` stamps key nodes and there were none. Server-assisted invalidation only ever reports keys, +so a command with no keys can never be invalidated by anything. `TIME`, `PING`, `RANDOMKEY`, `INFO` would +all have been permanently stale. This also removes a slice of the non-deterministic problem for free, since +several of those commands are keyless anyway. --- +### 6.10 Decision log + +What was chosen, what was rejected, and why. Several of these were reversed during implementation; the +reversals are the useful part. + +| Decision | Rejected alternative | Why | +| --- | --- | --- | +| Reference counting (`RefCountedBuffer`) | Neuterable `Dispose` + `TransferOwnership`, as §6.4 originally sketched | Transfer makes every holder reason about whether ownership moved, and the answer is only known after dispatch. A count gives one rule: whoever retains, releases. | +| `AsLookupKey()` borrows for the probe | Always `Detach()` | `Detach` allocates a lease — **48 bytes, measured** — and on a cache *hit* the caller never wanted the buffer. The split also makes storing a borrowed key unexpressible, since a borrowed key cannot be retained. | +| Global monotonic generation tickets | Per-key counters | A per-key counter restarting at zero collides with a ticket an entry recorded before invalidation, so the entry validates against a key that *did* change. | +| Two independent tables | `key → set of entries` cross-index | The set must be maintained on every insert and eviction, an N-key entry lives in N sets, and a hot key's set can be a large fraction of the cache — so invalidation is O(entries), not O(1). | +| Entries hold the key's `Node` directly | Re-look-up table 2 per hit | Validation becomes a dereference and a compare, with no hashing on the hot path. Cost: a node leaving table 2 must be stamped invalid *first*, or entries pointing at it never learn. | +| Executor owns the send; cache is a participant | `cache.GetOrExecute(...)` | A cache that calls the executor must sit above dispatch and know how to send; and a *command* has no business knowing how to send itself. Splitting yields `IRespExecutor` + `IRespHandler`, which are `Message` + `ResultProcessor`. | +| One `Send` with an optional cache | Two overloads | The cached path *is* the uncached path plus a probe and a commit, so an uncacheable request falls through to the same tail instead of duplicating it. | +| `RespRequest` / `RespPayload` on both sides | `ReadOnlySpan` in, `byte[]` out | A span cannot cross an `await` **or be parked in a backlog for a resend** — so it rules out async *and* retries even synchronously. `byte[]` allocates per call. | +| `SendAsync` is not an `async` method | Plain `async` | `async` forbids `ref` parameters, and the frame must be consumed by reference. Keeping the probe synchronous also makes a cache hit complete with **no state machine and no `Task`**. | +| `TryComplete` takes the payload | `TryComplete` takes the bytes | The reply is already in a pooled reference-counted buffer; copying it to cache it is waste. | +| Caching is **opt-out** (`NoClientCache`) | Opt-in | Opt-in means touching every `IDatabase` method, and one omission makes the feature silently do nothing. The population that argued for opt-in turned out to be nearly empty — see below. | +| A separate flag bit | A new rung on the retry ladder | `WithCategory` says the caller's category wins, so opting out of caching would *replace* the retry category and change reconnect behaviour. A rung above `ReadOnly` also reads as more severe, so `<= ReadOnly` retry policies would stop retrying it. `CommandServerSpecific` sits outside the ladder for exactly this reason. | +| Non-determinism lives in command metadata | A `CommandFlags` bit | `SRANDMEMBER`/`SCAN`/`HRANDFIELD` are compile-time properties of our own enum; pushing them onto every call site is burden without benefit. | +| Keyless requests are never cached | Cache them | Invalidation only ever reports **keys**, so a keyless entry is vacuously valid for the life of the process — not even a flush clears it. Found by building it, not by reasoning. | +| Handler maintains **both** key-mark forms | Re-derive arg indices on promotion | §5.2 assumed there were no spare bits — true of the *frame*, false of the writer, which is a stack `ref struct` with no size pressure. | +| >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | +| >62 arguments declines to cache | "Treat every argument as a key" | Over-invalidation is safe by protocol, but this registers *value* bytes as tracked keys, polluting the key table and inviting spurious invalidation from unrelated keys that happen to match a value. Safe, and invisibly degrading. | + +**One reversal worth recording explicitly.** The case for opt-in rested on "an external command that is +read-only, keyed, and untracked" — with `FT.SEARCH` as the example. That was wrong: `FT.*` takes an *index +name*, not a keyspace key, so it is keyless and already refused. The keyed module commands (`JSON.GET`, +`TS.RANGE`, `BF.EXISTS`) operate on real keys the server does track, and the Redis docs exclude the +probabilistic and time-series families on **efficiency** grounds — *"designed to be updated frequently, +which means caching has little or no benefit"* — which is exactly what an opt-out is for. With the +counter-example gone, the argument went with it. + +**A race that is not a defect.** Validation is not atomic across an entry's keys: validate A, an +invalidation for A lands, validate B, serve. The read could have completed a microsecond earlier and been +equally correct, so either outcome is a legitimate observation. It is bounded to reads that overlap the +invalidation, and everything after it is correct. Recorded as a deliberate tolerance rather than something +to fix. + ## 7. Analyzer rules The analyzer **does** reach consumers: `StackExchange.Redis.csproj:83-100` packs both @@ -1325,135 +1494,6 @@ Commands that return key names, and so need this: `RANDOMKEY`, `KEYS`, `SCAN`, t pops (`BLPOP`/`BRPOP`/`LMPOP`/`ZMPOP`/`BZPOPMIN`/`BZPOPMAX`), `XREAD`/`XREADGROUP` stream names, keyspace notifications, and script/`Execute` results. -#### 6.8 Transition: reusing `Message` rather than rewriting the command surface - -`RedisDatabase` builds a `Message` and pairs it with a `ResultProcessor`. Those are **the same two -halves as the new API** — a request that renders itself, and something that turns a reply into a result — -so the existing command surface can feed the new pipeline without being rewritten. `RespFrameWriter` is a -working demonstration (`MessageToRespFrameTests`). - -| New API | Existing equivalent | -| --- | --- | -| the rendered request | `Message` + `MessageWriter` | -| `IRespHandler.Parse` | `ResultProcessor.SetResultCore(..., ref RespReader)` | -| cluster slot | `Message.GetHashSlot` — already computed, so **nothing to fold during the write** | -| argument count | already in the `*N\r\n` header the writer emits | -| key prefixes, channel prefix, command map | already applied by `MessageWriter` | - -**What bytes cannot supply is which arguments were keys**, which is why this is a writer and not a post-pass -over a rendered frame — §5.2's finding applies directly. The saving grace is that `MessageWriter` kept the -distinction at the call site: `Write(in RedisKey)` is a separate overload from `WriteBulkString(in -RedisValue)`. So the whole integration is **one hook** — `Write(in RedisKey)` reports the current offset — -plus an `IBufferWriter` that accumulates and packs the marks. - -Notes from building it: - -- `MessageWriter` is a `readonly ref struct`, so it cannot accumulate marks itself. The recorder is a - reference to the target writer, resolved **once per message** in the constructor (`writer as - RespFrameWriter`), so the per-key cost is a null check on an already-loaded field. -- **Cost: below the noise floor.** A/B on `SET key value`: 66.96 ns with the hook, 68.77 ns without — the - hooked build measured *faster*, which is proof the difference is run-to-run variance rather than signal. - So the cost is bounded below ~3%, not that it is zero. -- Offsets suffice for ≤2 keys; beyond that the frame's encoding is argument *indices*, which the recorder - derives by walking the finished frame once — off any hot path, and the same walk `TryGetKeys` does in - reverse. -- Both routes render **byte-identically**, pinned by a test. That is a correctness property, not tidiness: - the frame is the cache key, so two routes that disagreed would cache the same logical command twice. - -Still open for a real transition: a cacheability predicate (Redis excludes `FT.*`, probabilistic and -time-series types, and non-deterministic commands such as `HRANDFIELD`/`ZRANDMEMBER`/`HSCAN`), and running -a `ResultProcessor` against a cached payload — it takes `ref RespReader`, which `RespPayload.GetReader()` -supplies, but it also wants a `PhysicalConnection` and `Message` for error context. - -#### 6.9 Cacheability: gate on the retry category, fail closed - -Cacheability cannot be a list of command names. `FT.*` is not in this library at all — it lives in -NRedisStack, reaching the server through `Execute`/`ExecuteAsync` — so any rule expressed as "these -commands are excluded" is unenforceable for exactly the commands most likely to be wrong. - -The flags already model this. The retry category is a 5-bit severity ladder in `CommandFlags` -(`Message.MaskRetryCategory`, bits 13–17), and `Message.UserSelectableFlags` **already includes it**, so an -external surface can declare a category today with no new API. So the gate is: - -```csharp -var category = flags & Message.MaskRetryCategory; -return category != 0 && category <= CommandFlags.CommandRetryReadOnly; -``` - -**Both halves matter.** Zero means "nobody declared one", and zero sorts *below* `CommandRetryReadOnly` on -the ladder — so a naive `<=` would read "nobody said" as "safe to cache", which is precisely backwards for -commands this library does not define. Undeclared must mean uncacheable. That is pinned by a test, because -it is the one that fails open if written carelessly. - -`flags` is therefore **not optional** on `Send`/`SendAsync`. Every `IDatabase` method in this library -already carries flags; whether a command may be cached is a property of the command, and the caller has to -say. - -**Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. Read-only -commands that must still not be cached: non-deterministic ones (`SRANDMEMBER`, `HRANDFIELD`, -`ZRANDMEMBER`) and cursor-based ones (`SCAN`, `HSCAN`). Those are *our* commands, so they belong in -command metadata rather than in flags — a compile-time property of our own enum should not be pushed onto -every call site. - -##### Opt-out, not opt-in - -The caller-facing control is **`CommandFlags.NoClientCache`** (bit 19), and caching is otherwise on by -default for anything that clears the gates. Opt-in was considered and rejected: it would mean touching -every `IDatabase` method, and a single omission makes the feature silently do nothing. - -The worry that argued for opt-in was an external command that is read-only, keyed, and *not* tracked by -the server — it would be cached and never invalidated. On inspection that population is close to empty: - -- `FT.*` takes an **index name, not a keyspace key**, so it is keyless and the rule above already refuses - it. (This was my counter-example, and it was simply wrong.) -- Probabilistic and time-series types (`BF.*`, `TS.*`) are keyed on *real* keyspace keys, so tracking and - invalidation work normally. The Redis docs exclude them because *"these types are designed to be updated - frequently, which means caching has little or no benefit"* — an efficiency argument, not a correctness - one, and precisely what an opt-out is for. - -What remains is a third party who writes their own module, enables client-side caching, declares a -read-only retry category, and whose module reads are not registered for invalidation by the server. Note -that doing *nothing* is already safe: an undeclared category is uncacheable, so the failure needs a -positive act of mis-declaration. And caching is globally opt-in in the first place. Treating that as caller -error is consistent with how this same enum already treats retry categories, where mis-declaring gets you -duplicate writes on a reconnect — a worse outcome that we already trust callers to avoid. - -`NoClientCache` suppresses the **probe as well as the store**: opting out has to mean the caller does not -receive a cached answer either, not merely that this reply is not kept. - -##### Why not a new rung on the retry ladder - -Tempting — it is a numeric range with gaps — but no: - -- **The caller wins on the ladder.** `WithCategory` is explicit: *"if the user has already specified a - category, that wins."* So opting out of caching via the category would *replace* the retry category, and - a caller suppressing caching on a churny value would silently change reconnect behaviour. -- **Inserting above `ReadOnly` breaks every `<=`.** A "read-only but uncacheable" rung reads as more - severe, so retry policies testing `<= CommandRetryReadOnly` would stop retrying it: a caching annotation - causing a retry regression. Inserting *below* avoids that but forces recategorising every read-only - command and leaves `ReadOnly` meaning "not cacheable". -- **The codebase already decided this.** `CommandServerSpecific` sits outside the ladder because it is - *"an orthogonal flag, not part of the `<=`-comparable severity ladder"*. Same shape, same answer. The - ladder orders one axis — is it safe to send again; cacheability asks another — will invalidation tell me - when this changes. - -##### Diagnosability - -The failure this design can still produce is silent and durable: something wrongly cached serves stale data -forever, with no error and no log. So the fill path keeps four counters — `Stored`, `RefusedByFlags`, -`RefusedNoKeys`, `RefusedRaced`. They are incremented only on a miss, which has already paid for a round -trip, so a cache hit costs nothing. "Why is this stale?" and "why is nothing being cached?" should both be -answerable without a debugger. - -**Keyless commands are never cached.** Found by building this: `AllValid` over an empty dependency list is -vacuously `true`, so a keyless entry was valid *for the life of the process* — not even a flush cleared it, -since `OnFlush` stamps key nodes and there were none. Server-assisted invalidation only ever reports keys, -so a command with no keys can never be invalidated by anything. `TIME`, `PING`, `RANDOMKEY`, `INFO` would -all have been permanently stale. This also removes a slice of the non-deterministic problem for free, since -several of those commands are keyless anyway. - ---- - ## 9. The spike in this repo A working spike. The surface is public but gated behind `SER010`/`SER011` — see §9.1. @@ -1468,6 +1508,17 @@ A working spike. The surface is public but gated behind `SER010`/`SER011` — se | `src/StackExchange.Redis/Interpolated/RespFragment.cs` | pre-framed token runs + the `[Resp]` marker | | `tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs` | 7 worked examples, each asserting the exact frame | | `tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs` | 16 tests; declarations only - the generator supplies the bodies | +| `src/StackExchange.Redis/Interpolated/RespRequest.cs` | the rendered request; also the cache key (§6.4) | +| `src/StackExchange.Redis/Interpolated/RespPayload.cs` | a reply as a pooled, reference-counted blob (§6.4) | +| `src/StackExchange.Redis/Interpolated/RespClientCache.cs` | table 1: `(request, db)` to payload + generations (§6.6) | +| `src/StackExchange.Redis/Interpolated/RespKeyTable.cs` | table 2: key bytes to a generation; the only thing invalidation touches (§6.6) | +| `src/StackExchange.Redis/Interpolated/RespExecutor.cs` | `IRespExecutor`, `IRespHandler`, and the `Send` orchestration (§6.7) | +| `src/StackExchange.Redis/Interpolated/RespFrameWriter.cs` | renders an existing `Message` into a `RespFrame` (§6.8) | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs` | 12 tests: zero-alloc hits, use-after-release, concurrent readers vs eviction | +| `tests/StackExchange.Redis.Tests/RespClientCacheTests.cs` | 44 tests: invalidation, the in-flight race, flag gates, counters | +| `tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs` | 7 tests: existing `Message` objects through the new pipeline | +| `tests/StackExchange.Redis.Tests/InterpolatedWriterCapacityTests.cs` | buffer arithmetic asserted directly, where pool slack cannot mask it | +| `tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs` | `OnInvalidate` under a broadcasting flood | Green on net10.0 and net8.0 (58 tests); net481 compiles; `-c Release /p:CI=true /p:RunAnalyzers=true` clean. @@ -1744,10 +1795,9 @@ Notes from building it, in case they bite again: route by slot. `RedisChannel` carries a `KeyRouted` option (`Subscription.cs:83`) that presumably ought to gate it, and sharing one `_slot` field between keys and channels conflates two different things. Visible in the worked example as `ChannelPrefix` reporting `slot=5631` for a plain `PUBLISH`. -- **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` must advance the key-mark bit - cursor by its arg count, not by 1. Either forbid keys in `Raw` (rule 5) or have `Raw` carry its own - bitmap to shift and OR in. -- **Promotion seam** (§5.2) — re-derive arg indices by walking, or something better. +- **`Raw` multi-arg and the bit cursor.** A fragment with `ArgCount > 1` advances `_argIndex` by its arg + count, which keeps the key-mark bitmap aligned — but `Raw` still cannot itself contain a key. Either + forbid that (rule 5) or have `Raw` carry its own bitmap to shift and OR in. - **Single-arg vs multi-arg `Raw`.** Restricting `Raw` to exactly one bulk string keeps `*N` a compile-time constant; allowing multi-arg costs runtime counting. Possibly two types. - **A runtime-validating `Raw` factory** for fragments assembled once at startup from config — the one @@ -1758,6 +1808,27 @@ Notes from building it, in case they bite again: - **Should the handler be a `ref struct`?** It holds only a `byte[]`. Ref struct prevents capture, copying and double-dispose, which is why it is right — but it also blocks `using var` + `ref` (§4) and any async retention. +Added while building the cache (§6.6-6.9): + +- **Command metadata for cacheability.** Non-deterministic (`SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`) + and cursor-based (`SCAN`, `HSCAN`) commands are read-only and keyed, so the flag gates pass them. They + need a per-command fact in our own metadata, *not* a `CommandFlags` bit — see §6.9. +- **Do module reads register for invalidation?** If the server tracks keys only for core command + dispatch, a keyed module read would be cached and never invalidated. Unresolved by the docs and worth + five minutes against a real server with a module loaded; it decides whether §6.9's opt-out story needs + a caveat for module authors. +- **Nothing turns tracking on.** There is no `CLIENT TRACKING` support, and the RESP3 `invalidate` push is + actively dropped — `PushKind` has no member for it, *and* `OnOutOfBand` requires the second element to + be an inline string, which an invalidate push's key array is not. Both need changing. RESP2 `REDIRECT` + already delivers invalidations via pub/sub today (`Issue2507`). +- **Replies are copied into the cache.** `RespPayload.Create` copies; the real executor should share the + reply frame's own lease via a reservation, as `RespResult` already does. +- **Running a `ResultProcessor` over a cached payload.** It takes `ref RespReader`, which + `RespPayload.GetReader()` supplies, but also wants a `PhysicalConnection` and `Message` for error + context — so it needs a synthetic context or a narrower interface (§6.8). +- **Bounding the cache.** Invalidated entries linger until `Sweep`, and the key table grows with distinct + keys seen. Both need a size bound; both fail closed, so bounding is safe (§6.6). + - **Static key bitmaps.** For fixed-arity commands the key positions are statically known, so the JIT may constant-fold the bitmap when the Append chain inlines. Not to be designed around, but the structure permits it and an analyzer could emit the constant if it matters. From d008d9192c45a92d7b55e32a346359548e99955f Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 21:43:30 +0100 Subject: [PATCH 052/360] Fix the demo's key helper, which was passing for a stale reason InterpolatedWriterDemo still asserted '' 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 '' for something that now resolves is actively misleading. --- .../InterpolatedWriterDemo.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs index f20f9042e..c11a448e9 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs @@ -15,11 +15,14 @@ public class InterpolatedWriterDemo /// Render the frame with CRLF shown as '|', so expectations stay readable. private static string Frame(in RespFrame frame) => Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + // sized from KeyCount, NOT a fixed two: a fixed buffer makes TryGetKeys report -1 for "target too + // small", which is indistinguishable here from "this frame cannot report its keys" private static string Keys(in RespFrame frame) { - Span ranges = stackalloc KeyRange[2]; - var count = frame.TryGetKeys(ranges); - if (count < 0) return ""; + var count = frame.KeyCount; + if (count < 0) return ""; + var ranges = new KeyRange[count]; + Assert.Equal(count, frame.TryGetKeys(ranges)); var parts = new string[count]; for (int i = 0; i < count; i++) parts[i] = Encoding.UTF8.GetString(frame.GetKey(ranges[i]).ToArray()); return string.Join(",", parts); @@ -81,7 +84,9 @@ public void VariadicWithSharedHashTag() using var frame = Cluster.Execute(ref cmd); Assert.Equal("*4|$3|DEL|$5|{u}:a|$5|{u}:b|$5|{u}:c|", Frame(frame)); - Assert.Equal("", Keys(frame)); // beyond two keys the inline offsets give out + // beyond two keys the inline offsets give out, but the argument-index bitmap still resolves them + Assert.True(frame.KeysNeedScan); + Assert.Equal("{u}:a,{u}:b,{u}:c", Keys(frame)); Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"{u}:a"), frame.Slot); } From 66fc890639a816e8d0bc3f9799d9744d59ed4568 Mon Sep 17 00:00:00 2001 From: mgravell Date: Sun, 13 Sep 2026 23:09:25 +0100 Subject: [PATCH 053/360] Instrument the stampede rather than assuming it: RedundantFills 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. --- design/interpolated-resp-writer.md | 62 +++++++++++++++++++ .../Interpolated/RespClientCache.cs | 15 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../RespClientCacheTests.cs | 20 ++++++ 4 files changed, 98 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 872826428..656ce67f5 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1294,6 +1294,67 @@ several of those commands are keyless anyway. --- +### 6.11 Request combining: instrument first + +`HybridCache` collapses concurrent misses onto one in-flight operation: the first caller installs a +placeholder carrying a `TaskCompletionSource`, later callers join it, and all complete or fail together. +It is the standard answer to a cache stampede. Whether it earns its complexity *here* is a different +question, because the economics are not the same. + +**A miss costs far less here.** A `HybridCache` miss invokes an arbitrary factory — a database query, an +HTTP call, hundreds of milliseconds. A miss here is a Redis round trip on an already-multiplexed +connection: a thousand concurrent misses become a thousand pipelined commands and a thousand O(1) server +lookups. That is not a stampede in the damaging sense, so the default answer is "not needed". + +**Two cases flip it, and the first is self-inflicted.** Redis requires the client to drop its whole cache +when a connection is lost (§6.6), so every hot key re-fetches simultaneously — precisely when the +connection has just been re-established. And for large values, a thousand concurrent 1MB misses is a +gigabyte of network and a thousand pooled buffers to produce one entry. + +#### Cancellation makes this a now-decision, not a later one + +v3 adds cancellation — `RespContext` carries a token and `SendAsync` takes one — which changes the shape. +The naive implementation is then *actively wrong*: passing the first caller's token to the shared send +means one caller's cancellation aborts everyone who joined. The shared send must use a **cache-owned** +token, with each waiter observing its own independently. So if cancellation is arriving anyway, this +wants deciding alongside it rather than retrofitted around it. + +**Last-man-standing is not a correctness requirement here**, though — and not because cancellation is +absent, but because **the cache is a stakeholder independent of the callers**. In `HybridCache`, if every +caller cancels, the work is pointless; there is nobody left who wants it. Here, completing the fill +populates a shared cache that later callers will hit, so it has standalone value. Let the fill complete +and commit it, and let each waiter observe its own token. Withdrawing the command when the last waiter +leaves *and* it has not yet been sent is then an optimisation, not a requirement — which removes the part +that is genuinely awkward in `HybridCache`. + +#### A tolerance to state, and a cheap mitigation + +Combining can hand a joiner data **older than an independent read would have given it**. If the leader +sends at T0, a write lands at T0.5, and a joiner arrives at T1, the joiner receives pre-write data for a +request that began strictly *after* the write — where its own request would have seen the write. That is +transient rather than permanent, so it is tolerable, but it should be a stated tolerance rather than an +accident. + +The mitigation is nearly free: **do not join a fill whose generations have already been stamped invalid.** +The leader's dependencies are right there, so a joiner arriving after an invalidation simply sends its +own request. + +#### Constraints for whenever it is built + +- `NoClientCache` callers must never join — they asked not to participate in cache machinery at all. +- The in-flight table is keyed by `(frame, database)`, like table 1. +- The `TaskCompletionSource` is allocated only on a miss, so the zero-allocation hit is unaffected. +- Per-waiter cancellation wants `Task.WaitAsync`, which does not exist on `netstandard2.0`/`net461`; that + needs a linked-TCS polyfill on down-level targets. + +#### Decision: measure first + +Not built. `RespClientCache.RedundantFills` counts fills that completed only to find the same request +already cached — two or more callers missing on the same request concurrently, which is exactly what +combining would have collapsed. It costs one increment on an already-cold path and needs no in-flight +table, so it does not presuppose the design it is evaluating. Expect near zero for ordinary traffic and a +spike after a flush. + ### 6.10 Decision log What was chosen, what was rejected, and why. Several of these were reversed during implementation; the @@ -1317,6 +1378,7 @@ reversals are the useful part. | Keyless requests are never cached | Cache them | Invalidation only ever reports **keys**, so a keyless entry is vacuously valid for the life of the process — not even a flush clears it. Found by building it, not by reasoning. | | Handler maintains **both** key-mark forms | Re-derive arg indices on promotion | §5.2 assumed there were no spare bits — true of the *frame*, false of the writer, which is a stack `ref struct` with no size pressure. | | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | +| Request combining deferred, with a counter | Build it now | A miss is a round trip on a multiplexed connection, not an arbitrary factory call, so the stampede economics differ by orders of magnitude. `RedundantFills` measures whether it is real without presupposing the design (§6.11). | | >62 arguments declines to cache | "Treat every argument as a key" | Over-invalidation is safe by protocol, but this registers *value* bytes as tracked keys, polluting the key table and inviting spurious invalidation from unrelated keys that happen to match a value. Safe, and invisibly degrading. | **One reversal worth recording explicitly.** The case for opt-in rested on "an external command that is diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index abeaa3433..0ae2d9ed2 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -46,6 +46,7 @@ public sealed class RespClientCache : IDisposable private long _refusedByFlags; private long _refusedNoKeys; private long _refusedRaced; + private long _redundantFills; /// Create a cache. /// Initial size hint for the tracked-key table. @@ -81,6 +82,19 @@ public sealed class RespClientCache : IDisposable /// Fills refused because an invalidation landed while the command was in flight. public long RefusedRaced => Volatile.Read(ref _refusedRaced); + /// + /// Fills that completed only to find the same request already cached by someone else - i.e. two or + /// more callers missed on the same request concurrently and all of them went to the server. + /// + /// + /// This is the stampede signal, and it is measured rather than assumed because request combining is + /// real complexity and the economics here are not HybridCache's: a miss is a round trip on an + /// already-multiplexed connection, not an arbitrary factory call. Expect it to be near zero for + /// ordinary traffic and to spike after a flush, since dropping the cache on disconnect makes every + /// hot key re-fetch at once. See the design notes, section 6.11. + /// + public long RedundantFills => Volatile.Read(ref _redundantFills); + /// /// Invalidate one key, as reported by the server. Allocation-free, and cheap when the key is not /// cached here. @@ -269,6 +283,7 @@ public bool TryComplete(in RespFill fill, RespPayload response) } // somebody else filled the same request first; theirs is as good as ours + Interlocked.Increment(ref _redundantFills); response.Release(); stored.Dispose(); fill.Key.Dispose(); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e8e4917c7..e342522e7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -21,6 +21,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RedundantFills.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedByFlags.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index c5679e697..18f37229e 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -628,6 +628,26 @@ public void NoClientCacheIsUserSelectable() Message.UserSelectableFlags & CommandFlags.NoClientCache); } + [Fact] + public void RedundantFillsCountConcurrentMissesOnTheSameRequest() + { + using var cache = new RespClientCache(); + + // two callers miss on the same request and both go to the server - exactly what request + // combining would have collapsed into one round trip + var first = Get("abc"); + Assert.True(cache.TryBeginFill(ref first, 0, CommandFlags.CommandRetryReadOnly, out var a)); + var second = Get("abc"); + Assert.True(cache.TryBeginFill(ref second, 0, CommandFlags.CommandRetryReadOnly, out var b)); + + Assert.True(Complete(cache, a, "$5\r\nhello\r\n")); + Assert.False(Complete(cache, b, "$5\r\nhello\r\n")); // lost the race; the first entry stands + + Assert.Equal(1, cache.Stored); + Assert.Equal(1, cache.RedundantFills); + Assert.Equal(1, cache.Count); + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From a76284e177ad8f65e537d18e6e5c88e2e08306ba Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 07:25:22 +0100 Subject: [PATCH 054/360] Never cache errors; do cache nulls. Settle the cancellation model. 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. --- design/interpolated-resp-writer.md | 32 ++++++++++++++ .../Interpolated/RespClientCache.cs | 42 +++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../RespClientCacheTests.cs | 42 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 656ce67f5..682a3a14c 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1347,6 +1347,36 @@ own request. - Per-waiter cancellation wants `Task.WaitAsync`, which does not exist on `netstandard2.0`/`net461`; that needs a linked-TCS polyfill on down-level targets. +#### The agreed cancellation model + +Settled: **the request completes or fails by itself, including caching; a caller's cancellation applies +only to that caller's await.** No extra token, no waiter tracking, no last-man-standing. `HybridCache` +needs all of that because it fronts arbitrary external systems whose work has no value once nobody is +waiting; here the fill populates a shared cache, so it is worth finishing regardless of who is still +listening. + +### 6.12 Errors are not cached; nulls are + +`TryComplete` refuses a reply whose first byte is `-` (simple error) or `!` (RESP3 bulk error). + +The invariant that makes this cache sound is that a reply is **a function of the keys it depends on**, and +that the server will tell us when those change. An error need not be: it can come from server +configuration, cluster topology, ACLs, memory pressure or a module's own state, none of which key +invalidation covers — so nothing would ever evict it. Caching one turns a **transient failure into a +permanent one**, which is the same class of bug as caching a keyless command (§6.9). + +`-WRONGTYPE` genuinely *is* a function of the key and would be invalidated correctly, but separating those +cases needs per-code knowledge for something that should be rare — and if errors are not rare, caching +them hides the problem instead of solving it. Hence `RefusedError`: a non-trivial count is itself worth +investigating. + +**A null is a value, not a failure**, in all three spellings — `$-1` (RESP2 null bulk), `*-1` (RESP2 null +array) and `_` (RESP3). Redis tracks every key *"mentioned in the context of a read-only command"*, +whether or not it exists, so creating the key invalidates the entry. **Negative caching therefore works, +and works correctly** — which is unusual enough to be worth stating. No null spelling begins with `-` or +`!`, so the cheap first-byte test does not need to enumerate them; `RespReader.IsNull` is the right tool +if the classification is ever needed for its own sake. + #### Decision: measure first Not built. `RespClientCache.RedundantFills` counts fills that completed only to find the same request @@ -1378,6 +1408,8 @@ reversals are the useful part. | Keyless requests are never cached | Cache them | Invalidation only ever reports **keys**, so a keyless entry is vacuously valid for the life of the process — not even a flush clears it. Found by building it, not by reasoning. | | Handler maintains **both** key-mark forms | Re-derive arg indices on promotion | §5.2 assumed there were no spare bits — true of the *frame*, false of the writer, which is a stack `ref struct` with no size pressure. | | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | +| Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | +| Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | | Request combining deferred, with a counter | Build it now | A miss is a round trip on a multiplexed connection, not an arbitrary factory call, so the stampede economics differ by orders of magnitude. `RedundantFills` measures whether it is real without presupposing the design (§6.11). | | >62 arguments declines to cache | "Treat every argument as a key" | Over-invalidation is safe by protocol, but this registers *value* bytes as tracked keys, polluting the key table and inviting spurious invalidation from unrelated keys that happen to match a value. Safe, and invisibly degrading. | diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 0ae2d9ed2..77a93d903 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using RESPite; +using RESPite.Messages; namespace StackExchange.Redis.Interpolated { @@ -47,6 +48,7 @@ public sealed class RespClientCache : IDisposable private long _refusedNoKeys; private long _refusedRaced; private long _redundantFills; + private long _refusedError; /// Create a cache. /// Initial size hint for the tracked-key table. @@ -95,6 +97,13 @@ public sealed class RespClientCache : IDisposable /// public long RedundantFills => Volatile.Read(ref _redundantFills); + /// Fills refused because the reply was an error. + /// + /// See for why errors are not cacheable. A non-trivial count here is + /// worth investigating on its own: errors should be rare, and caching them would have hidden that. + /// + public long RefusedError => Volatile.Read(ref _refusedError); + /// /// Invalidate one key, as reported by the server. Allocation-free, and cheap when the key is not /// cached here. @@ -254,6 +263,13 @@ public bool TryComplete(in RespFill fill, RespPayload response) if (response is null) throw new ArgumentNullException(nameof(response)); if (fill.Key.IsEmpty) return false; + if (IsError(response.Span)) + { + Interlocked.Increment(ref _refusedError); + fill.Key.Dispose(); + return false; + } + if (!Dependency.AllValid(fill.Dependencies)) { Interlocked.Increment(ref _refusedRaced); @@ -328,6 +344,32 @@ public void Dispose() _keys.InvalidateAll(); } + /// + /// Whether a reply is an error - a simple error or, in RESP3, a bulk error. + /// + /// + /// + /// Errors are never 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 will say when those change. An error need not be: + /// it can come from server configuration, cluster topology, ACLs, memory pressure or a module's own + /// state, none of which key invalidation covers - so nothing would ever evict it. + /// + /// + /// That turns a transient failure into a permanent one, which is the same class of bug as caching a + /// keyless command. -WRONGTYPE genuinely IS a function of the key and would be invalidated + /// correctly, but telling those apart needs per-code knowledge for a case that should be rare - + /// and if errors are not rare, caching them hides the problem rather than solving it. + /// + /// + /// A null reply is not an error: it is a value, and Redis tracks every key "mentioned in the + /// context of a read-only command" whether or not it exists, so creating the key invalidates the + /// entry. Negative caching therefore works, and works correctly. + /// + /// + private static bool IsError(ReadOnlySpan response) + => !response.IsEmpty + && (response[0] == (byte)RespPrefix.SimpleError || response[0] == (byte)RespPrefix.BulkError); + /// /// Whether the command's retry category permits caching at all. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e342522e7..4dce6c062 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,6 +23,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RedundantFills.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedByFlags.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedError.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 18f37229e..621f0c6fc 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -648,6 +648,48 @@ public void RedundantFillsCountConcurrentMissesOnTheSameRequest() Assert.Equal(1, cache.Count); } + [Theory] + [InlineData("$-1\r\n")] // RESP2 null bulk string + [InlineData("*-1\r\n")] // RESP2 null array - a different spelling, still a value + [InlineData("_\r\n")] // RESP3 null + public void NullRepliesAreCached(string reply) + { + using var cache = new RespClientCache(); + var frame = Get("missing"); + Assert.True(cache.TryBeginFill(ref frame, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + + // a null is a VALUE, not a failure, in all three spellings. Redis tracks every key "mentioned in + // the context of a read-only command", found or not, so creating the key invalidates this entry - + // negative caching that is actually correct. Only '-' and '!' are errors, and no null starts with + // either, so the cheap first-byte test does not need to enumerate the null forms. + Assert.True(Complete(cache, fill, reply)); + Assert.True(TryRead(cache, "missing", out var text)); + Assert.Equal(reply.Replace("\r\n", "|"), text); + + Assert.True(cache.OnInvalidate(Utf8("missing"))); + Assert.False(TryRead(cache, "missing", out _)); + Assert.Equal(0, cache.RefusedError); + } + + [Theory] + [InlineData("-ERR something went wrong\r\n")] + [InlineData("-WRONGTYPE Operation against a key holding the wrong kind of value\r\n")] + [InlineData("-MOVED 1234 127.0.0.1:7001\r\n")] + [InlineData("!21\r\nSYNTAX invalid syntax\r\n")] + public void ErrorRepliesAreNotCached(string reply) + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + + // an error is not necessarily a function of the tracked keys - it can depend on server config, + // topology, ACLs, a module's state - so the invariant that makes this cache sound does not hold + // for it, and nothing may ever invalidate it. Caching one turns a transient failure permanent. + Assert.False(Complete(cache, fill, reply)); + Assert.Equal(0, cache.Count); + Assert.Equal(1, cache.RefusedError); + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From c4aacd18f808ce1b446c9a2ec8698acb0feaa96e Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 07:27:40 +0100 Subject: [PATCH 055/360] Classify replies with RespReader, not the first byte 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. --- design/interpolated-resp-writer.md | 27 ++++++++++-- .../Interpolated/RespClientCache.cs | 27 +++++++++--- .../RespClientCacheTests.cs | 43 +++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 682a3a14c..3863487d4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1357,7 +1357,8 @@ listening. ### 6.12 Errors are not cached; nulls are -`TryComplete` refuses a reply whose first byte is `-` (simple error) or `!` (RESP3 bulk error). +`TryComplete` refuses a reply whose first **content element** is an error — simple (`-`) or, in RESP3, +bulk (`!`). The invariant that makes this cache sound is that a reply is **a function of the keys it depends on**, and that the server will tell us when those change. An error need not be: it can come from server @@ -1373,9 +1374,26 @@ investigating. **A null is a value, not a failure**, in all three spellings — `$-1` (RESP2 null bulk), `*-1` (RESP2 null array) and `_` (RESP3). Redis tracks every key *"mentioned in the context of a read-only command"*, whether or not it exists, so creating the key invalidates the entry. **Negative caching therefore works, -and works correctly** — which is unusual enough to be worth stating. No null spelling begins with `-` or -`!`, so the cheap first-byte test does not need to enumerate them; `RespReader.IsNull` is the right tool -if the classification is ever needed for its own sake. +and works correctly** — which is unusual enough to be worth stating. + +#### Why this cannot be a first-byte test + +The obvious implementation — look at `response[0]` — is **wrong**, and wrong in the way that survives +testing. RESP3 permits attribute metadata (`|`) ahead of a value, and **nothing in the specification +exempts errors or nulls from carrying it**. So `|1\r\n$6\r\nttl-ms\r\n:1000\r\n-ERR …` begins with +`|`, passes a leading-byte check, and gets cached as though it were data — a permanently cached error, +which is exactly the failure §6.12 exists to prevent. + +No server is known to emit attributes today. That is precisely why it would go unnoticed: the bug is +latent until a server, a proxy, or a future protocol revision starts using a feature the protocol already +allows. + +`RespReader.TryMoveNext(checkError: false)` skips attributes and lands on the first content element, and +`RespReader.IsError` classifies it. `checkError: false` matters — the default overload *throws* on an +error, which is the very thing being detected. A reply with no content element at all (metadata only, or +empty) is refused too: unclassifiable fails closed. + +Pinned by tests that fail against the first-byte implementation. #### Decision: measure first @@ -1408,6 +1426,7 @@ reversals are the useful part. | Keyless requests are never cached | Cache them | Invalidation only ever reports **keys**, so a keyless entry is vacuously valid for the life of the process — not even a flush clears it. Found by building it, not by reasoning. | | Handler maintains **both** key-mark forms | Re-derive arg indices on promotion | §5.2 assumed there were no spare bits — true of the *frame*, false of the writer, which is a stack `ref struct` with no size pressure. | | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | +| Classify the reply with `RespReader` | Test `response[0]` | RESP3 attributes may precede any value, and nothing exempts errors from carrying them - so a first-byte test caches an error hidden behind metadata. Latent today because no server emits attributes, which is what makes it dangerous (§6.12). | | Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | | Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | | Request combining deferred, with a counter | Build it now | A miss is a round trip on a multiplexed connection, not an arbitrary factory call, so the stampede economics differ by orders of magnitude. `RedundantFills` measures whether it is real without presupposing the design (§6.11). | diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 77a93d903..7bd188a44 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -263,7 +263,7 @@ public bool TryComplete(in RespFill fill, RespPayload response) if (response is null) throw new ArgumentNullException(nameof(response)); if (fill.Key.IsEmpty) return false; - if (IsError(response.Span)) + if (!IsCacheableReply(response.Span)) { Interlocked.Increment(ref _refusedError); fill.Key.Dispose(); @@ -345,7 +345,7 @@ public void Dispose() } /// - /// Whether a reply is an error - a simple error or, in RESP3, a bulk error. + /// Whether a reply may be cached: it has a content element, and that element is not an error. /// /// /// @@ -365,10 +365,27 @@ public void Dispose() /// context of a read-only command" whether or not it exists, so creating the key invalidates the /// entry. Negative caching therefore works, and works correctly. /// + /// + /// This reads the first content element, not the first byte. RESP3 permits attribute + /// metadata (|) ahead of a value, and nothing in the specification exempts errors or nulls + /// from carrying it - so a leading-byte test would classify |1\r\n...\r\n-ERR ... as + /// cacheable and store an error. No server is known to emit that today, which is exactly why it + /// would go unnoticed. skips attributes, and + /// checkError: false stops it throwing on the case being detected. + /// + /// + /// A reply with no content element at all - metadata only, or empty - is also refused: it cannot be + /// classified, and unclassifiable fails closed. + /// /// - private static bool IsError(ReadOnlySpan response) - => !response.IsEmpty - && (response[0] == (byte)RespPrefix.SimpleError || response[0] == (byte)RespPrefix.BulkError); + private static bool IsCacheableReply(ReadOnlySpan response) + { + var reader = new RespReader(response); + + // read the first CONTENT element, not the first byte. TryMoveNext skips attribute metadata, + // and checkError:false stops it throwing on the very thing we are trying to detect. + return reader.TryMoveNext(checkError: false) && !reader.IsError; + } /// /// Whether the command's retry category permits caching at all. diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 621f0c6fc..a145432de 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -690,6 +690,49 @@ public void ErrorRepliesAreNotCached(string reply) Assert.Equal(1, cache.RefusedError); } + // RESP3 attribute metadata: |1 , which may legally precede any reply + private const string Attribute = "|1\r\n$6\r\nttl-ms\r\n:1000\r\n"; + + [Theory] + [InlineData(Attribute + "-ERR something went wrong\r\n")] + [InlineData(Attribute + "!21\r\nSYNTAX invalid syntax\r\n")] + public void ErrorsBehindLeadingAttributesAreStillRefused(string reply) + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + + // a first-byte test would see '|' and cache the error behind it. No server is known to emit + // attributes today, which is precisely why this would have gone unnoticed. + Assert.False(Complete(cache, fill, reply)); + Assert.Equal(0, cache.Count); + Assert.Equal(1, cache.RefusedError); + } + + [Fact] + public void ValuesBehindLeadingAttributesAreStillCached() + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + + Assert.True(Complete(cache, fill, Attribute + "$5\r\nhello\r\n")); + Assert.Equal(1, cache.Stored); + Assert.Equal(0, cache.RefusedError); + } + + [Fact] + public void RepliesWithNoContentElementAreRefused() + { + using var cache = new RespClientCache(); + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, CommandFlags.CommandRetryReadOnly, out var fill)); + + // metadata and nothing else: cannot be classified, so it fails closed rather than being stored + Assert.False(Complete(cache, fill, Attribute)); + Assert.Equal(0, cache.Count); + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From 4fbed257dec9871c2c70c9e18d08efb1ed95c32d Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 07:30:02 +0100 Subject: [PATCH 056/360] Fast byte test; parse only behind an attribute 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. --- design/interpolated-resp-writer.md | 27 +++++++++++++++---- .../Interpolated/RespClientCache.cs | 26 ++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 3863487d4..30b0bd283 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1388,12 +1388,28 @@ No server is known to emit attributes today. That is precisely why it would go u latent until a server, a proxy, or a future protocol revision starts using a feature the protocol already allows. -`RespReader.TryMoveNext(checkError: false)` skips attributes and lands on the first content element, and -`RespReader.IsError` classifies it. `checkError: false` matters — the default overload *throws* on an -error, which is the very thing being detected. A reply with no content element at all (metadata only, or -empty) is refused too: unclassifiable fails closed. +The fix is not to parse every reply, though. **Attributes are the only construct that can precede a +value**, so if the first byte is not `|` then it *is* the first content element's prefix, and the cheap +test is **exact, not approximate**: -Pinned by tests that fail against the first-byte implementation. +```csharp +var prefix = response[0]; +if (prefix == (byte)RespPrefix.Attribute) return IsCacheableBehindAttributes(response); // NoInlining +return prefix != (byte)RespPrefix.SimpleError && prefix != (byte)RespPrefix.BulkError; +``` + +Protocol parsing is reserved for the branch that needs it, which — no server emitting attributes today — +is in practice never taken. The slow 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. + +On that path, `RespReader.TryMoveNext(checkError: false)` skips attributes and lands on the first content +element, and `RespReader.IsError` classifies it. `checkError: false` matters — the default overload +*throws* on an error, which is the very thing being detected. A reply with no content element at all +(metadata only, or empty) is refused too: unclassifiable fails closed. + +Both branches are pinned: the tests fail against a first-byte-only implementation, and they fail again if +the attribute path stops classifying. #### Decision: measure first @@ -1426,6 +1442,7 @@ reversals are the useful part. | Keyless requests are never cached | Cache them | Invalidation only ever reports **keys**, so a keyless entry is vacuously valid for the life of the process — not even a flush clears it. Found by building it, not by reasoning. | | Handler maintains **both** key-mark forms | Re-derive arg indices on promotion | §5.2 assumed there were no spare bits — true of the *frame*, false of the writer, which is a stack `ref struct` with no size pressure. | | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | +| Fast byte test, `RespReader` only behind an attribute | Parse every reply | Attributes are the only thing that can precede a value, so a non-`\|` first byte *is* the content prefix - the cheap test is exact, and parsing is reserved for a branch that is in practice never taken (§6.12). | | Classify the reply with `RespReader` | Test `response[0]` | RESP3 attributes may precede any value, and nothing exempts errors from carrying them - so a first-byte test caches an error hidden behind metadata. Latent today because no server emits attributes, which is what makes it dangerous (§6.12). | | Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | | Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 7bd188a44..73e77d753 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Threading; using RESPite; using RESPite.Messages; @@ -379,11 +380,32 @@ public void Dispose() /// /// private static bool IsCacheableReply(ReadOnlySpan response) + { + if (response.IsEmpty) return false; + + // 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 and this test is exact, not approximate. Parsing is + // reserved for the case that needs it - which, no server emitting attributes today, is never. + var prefix = response[0]; + if (prefix == (byte)RespPrefix.Attribute) return IsCacheableBehindAttributes(response); + + return prefix != (byte)RespPrefix.SimpleError && prefix != (byte)RespPrefix.BulkError; + } + + /// + /// The attribute case: skip the metadata and classify the element that follows. + /// + /// + /// Deliberately separate and not inlined. is a sizeable ref struct, + /// and constructing one in a cold branch changes codegen for the whole method - the same reason the + /// fallbacks in MessageWriter are split out. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool IsCacheableBehindAttributes(ReadOnlySpan response) { var reader = new RespReader(response); - // read the first CONTENT element, not the first byte. TryMoveNext skips attribute metadata, - // and checkError:false stops it throwing on the very thing we are trying to detect. + // checkError:false - the default overload throws on an error, which is what we are detecting return reader.TryMoveNext(checkError: false) && !reader.IsError; } From 9d38ec3fba71f018f0ff0f09f0e3542f3dc99ce7 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 07:31:56 +0100 Subject: [PATCH 057/360] Say it as a switch expression 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. --- design/interpolated-resp-writer.md | 9 ++++++--- .../Interpolated/RespClientCache.cs | 16 +++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 30b0bd283..5d6c0d59d 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1393,9 +1393,12 @@ value**, so if the first byte is not `|` then it *is* the first content element' test is **exact, not approximate**: ```csharp -var prefix = response[0]; -if (prefix == (byte)RespPrefix.Attribute) return IsCacheableBehindAttributes(response); // NoInlining -return prefix != (byte)RespPrefix.SimpleError && prefix != (byte)RespPrefix.BulkError; +return (RespPrefix)response[0] switch +{ + RespPrefix.Attribute => IsCacheableBehindAttributes(response), // NoInlining + RespPrefix.SimpleError or RespPrefix.BulkError => false, + _ => true, +}; ``` Protocol parsing is reserved for the branch that needs it, which — no server emitting attributes today — diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 73e77d753..563f13fe2 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -383,13 +383,15 @@ private static bool IsCacheableReply(ReadOnlySpan response) { if (response.IsEmpty) return false; - // 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 and this test is exact, not approximate. Parsing is - // reserved for the case that needs it - which, no server emitting attributes today, is never. - var prefix = response[0]; - if (prefix == (byte)RespPrefix.Attribute) return IsCacheableBehindAttributes(response); - - return prefix != (byte)RespPrefix.SimpleError && prefix != (byte)RespPrefix.BulkError; + // Attributes are the ONLY construct that can precede a value, so any other first byte IS the + // first content element's prefix - which makes this exact, not approximate. Parsing is reserved + // for the one case that needs it; no server emits attributes today, so that is in practice never. + return (RespPrefix)response[0] switch + { + RespPrefix.Attribute => IsCacheableBehindAttributes(response), + RespPrefix.SimpleError or RespPrefix.BulkError => false, + _ => true, + }; } /// From 150e6ffa0134eb7dfa795f78c2bd0f416db80a6c Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 07:36:41 +0100 Subject: [PATCH 058/360] Make the allocation assertions survive a full-suite run 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. --- .../AllocationAssert.cs | 44 +++++++++++++++++++ .../InterpolatedWriterCacheKeyTests.cs | 44 ++++++------------- .../RespClientCacheTests.cs | 14 +----- 3 files changed, 60 insertions(+), 42 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/AllocationAssert.cs diff --git a/tests/StackExchange.Redis.Tests/AllocationAssert.cs b/tests/StackExchange.Redis.Tests/AllocationAssert.cs new file mode 100644 index 000000000..6759201da --- /dev/null +++ b/tests/StackExchange.Redis.Tests/AllocationAssert.cs @@ -0,0 +1,44 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Allocation assertions that survive a full-suite run. +/// +/// +/// A single GC.GetAllocatedBytesForCurrentThread window is not reliable on its own: tiered JIT +/// recompilation can promote a method mid-loop and allocate on the measuring thread, which shows up as a +/// flake only under load - the case where it is least welcome. Taking the BEST of several windows keeps +/// the assertion exact rather than adding a tolerance: code that allocates per call allocates in every +/// window, while one-off runtime noise does not. +/// +internal static class AllocationAssert +{ + private const int Windows = 5; + + /// Assert that allocates nothing per call. + internal static void None(Action action, int iterations = 1000, int warmup = 500) + { + Assert.Equal(0, Measure(action, iterations, warmup)); + } + + /// The fewest bytes allocated across several measurement windows. + internal static long Measure(Action action, int iterations = 1000, int warmup = 500) + { + for (var i = 0; i < warmup; i++) action(); + + var best = long.MaxValue; + for (var window = 0; window < Windows; window++) + { + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) action(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + if (allocated < best) best = allocated; + if (best == 0) break; // cannot do better, and no reason to keep burning time + } + + return best; + } +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs index d5de638e4..c5a3c718a 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs @@ -88,16 +88,9 @@ public void LookupAllocatesNothingOnAHit() var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); cache.TryAdd(stored, payload); - // warm everything up: JIT, the pool's per-core stacks, the dictionary's buckets - for (var i = 0; i < 200; i++) Probe(cache); - - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 1000; i++) Probe(cache); - var after = GC.GetAllocatedBytesForCurrentThread(); - // the render rents from the pool and returns it, the key is a struct, the payload is already // allocated, and RespReader is a ref struct - so a steady-state hit should allocate nothing - Assert.Equal(0, after - before); + AllocationAssert.None(() => Probe(cache), iterations: 1000, warmup: 200); cache.TryRemove(stored, out _); payload.Dispose(); @@ -144,29 +137,20 @@ public void ABorrowedKeyCannotBeRetainedAndSoCannotBeStored() public void DetachAllocatesAndBorrowingDoesNot() { var ctx = new RespContext(); - for (var i = 0; i < 200; i++) - { - using var warm = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); - warm.AsLookupKey(); - ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(); - } - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 100; i++) - { - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); - frame.AsLookupKey(); - } - - var borrowed = GC.GetAllocatedBytesForCurrentThread() - before; - - before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 100; i++) - { - ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(); - } - - var detached = GC.GetAllocatedBytesForCurrentThread() - before; + var borrowed = AllocationAssert.Measure( + () => + { + using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + frame.AsLookupKey(); + }, + iterations: 100, + warmup: 200); + + var detached = AllocationAssert.Measure( + () => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(), + iterations: 100, + warmup: 200); // this is why AsLookupKey exists: Detach costs one RefCountedBuffer per call, which on a cache HIT // buys nothing, because the caller never wanted ownership diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index a145432de..1cfdc4ad5 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -110,20 +110,10 @@ public void InvalidationIsAllocationFree() var hit = Utf8("abc"); var miss = Utf8("some:other:key:that:is:not:here"); - for (var i = 0; i < 500; i++) - { - cache.OnInvalidate(hit); - cache.OnInvalidate(miss); - } - - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 10_000; i++) - { - cache.OnInvalidate(miss); // the broadcasting flood: keys we do not have - } + cache.OnInvalidate(hit); // BCAST hands us every key touched on the server; this path must not allocate at all - Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); + AllocationAssert.None(() => cache.OnInvalidate(miss), iterations: 10_000); } [Fact] From 05fc439b77728aa7fb5d11463435788d3e1e89dd Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 08:05:36 +0100 Subject: [PATCH 059/360] Record the context-as-extension-point plan (design notes only) 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. --- design/interpolated-resp-writer.md | 69 +++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 5d6c0d59d..0dd9c31ac 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1921,6 +1921,71 @@ Notes from building it, in case they bite again: --- +### 9.4 The context as the extension point + +The intended shape is a context (`db`, key prefix, cancellation, executor) reached from `IDatabase`/ +`IServer` via one new member, with the command surface hanging off it as **extension members**: + +```csharp +ctx.Strings.Set(key, value) => ctx.Execute(RedisCommands.Set, $"{key}{value}") +``` + +Three reasons, in the order different audiences feel them. + +**1. Discoverability.** `IDatabase` is a flat surface of several hundred methods; IntelliSense on `db.` is +not a navigable list, it is a wall. `ctx.Strings.`, `ctx.Hashes.`, `ctx.Sets.`, `ctx.Streams.` groups the +surface the way Redis documents itself — by data type — so the shape of the API teaches it. This is the +benefit an ordinary caller notices first, and on its own it would probably justify the change. + +**2. Module libraries become first class.** NRedisStack today reaches the server through +`db.Execute("FT.SEARCH", …)` or a parallel set of its own interfaces. Extension members over a shared +context mean `ctx.Search.Query(...)` composes exactly like `ctx.Strings.Set(...)` — same cancellation, +same key prefix, same cache participation, no wrapper interface and no forked surface. Module commands +also then arrive through the same `Send`, so they inherit the §6.9 cacheability gates automatically +instead of needing a parallel opt-out story. + +**3. It is the last break.** Adding the member is a breaking change, and adding to `IDatabase` has been +standard practice here, so the cost is familiar rather than novel. The difference is that this one ends +the sequence: once a context exists, every subsequent addition is an extension member and breaks nobody. +The one break buys the end of breaks. + +**That guarantee rests on a discipline, not on the type system.** The first "just this once" method added +to `IDatabase` after the context exists spends the break for nothing. Worth writing down, and eventually +worth an analyzer — this repo already gates hand-built fragments behind `SER011` on the same reasoning, +that the blast radius is not the author's own code. + +#### Four things to settle before building it + +1. **`ref readonly` and `async` do not mix.** A `ref readonly` local cannot cross an `await`, and the + command surface is `ValueTask`-first — so the context is copied into the state machine anyway and the + `ref` buys nothing on the only path that matters. At roughly four registers, copy it: return by value, + take `in` on parameters. And keep `RespContext` a `readonly struct`; making it a `ref struct` to "make + it cheap" would make it unusable in the very methods it exists for. + +2. **`RespRequest` must carry its own metadata first.** `Detach()` keeps bytes, lease, offset, length and + hash — and drops the key marks, the slot and the argument count. So inside + `IRespExecutor.Send(in RespRequest)` there is no way to ask which arguments were keys, and a cache + decorator cannot begin a fill. Widening the request (a `ulong`, two `int`s) is a prerequisite for the + executor-decorator model, not a refinement of it. + +3. **A retry executor needs the flags.** "Is this safe to resend" is the `CommandFlags` retry category, + and `Send` does not receive it. Same fix as (2), and they should land together or the decorator model + does not close. Retry otherwise fits well: it must hold the preformed payload across attempts, which is + exactly what `RespRequest.TryRetain` is for. + +4. **Decorator order is silent and load-bearing.** `cache(retry(raw))`: a hit must not traverse retry + logic, and a retry must not re-probe a cache it already missed. Nothing in the type system says so, so + it wants a test. + +**Cache and retry are not an either/or between "executor" and "context".** The decorator *is* an executor; +installing it is a `With` on the context. Behaviour composes in the executor chain, configuration composes +on the context. + +**Keep `IRespExecutor.Send` (the synchronous member).** Driving sync as +`AsTask().GetAwaiter().GetResult()` blocks a pool thread for a whole round trip, which today's sync path +deliberately avoids and which a large constituency depends on. Fine for a spike; but keeping the sync +member means a real sync path can arrive later without reshaping the API, and it costs nothing now. + ## 10. Open questions - **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which @@ -1937,7 +2002,9 @@ Notes from building it, in case they bite again: legitimate case the literal-only rule closes off. - **Public API commitment.** A public method taking the handler forces the handler type public, putting every `AppendFormatted` overload into `PublicAPI.Unshipped.txt` permanently. Can the interpolated - surface start internal (RESPite-side, used by SE.Redis) to buy room to iterate? + surface start internal (RESPite-side, used by SE.Redis) to buy room to iterate? §9.4 argues the opposite + direction for the *command* surface - one member on `IDatabase`, everything else extension members - + so these want reconciling. - **Should the handler be a `ref struct`?** It holds only a `byte[]`. Ref struct prevents capture, copying and double-dispose, which is why it is right — but it also blocks `using var` + `ref` (§4) and any async retention. From e87a7c8f24c5bbf2205a791116d97506b4b5ad6c Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 08:17:16 +0100 Subject: [PATCH 060/360] Widen RespRequest so a decorator can ask about what it is sending 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. --- design/interpolated-resp-writer.md | 33 +++++-- .../Interpolated/RespFrame.cs | 88 ++++++++++++------- .../Interpolated/RespRequest.cs | 51 ++++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 10 ++- .../RespClientCacheTests.cs | 44 ++++++++++ 5 files changed, 183 insertions(+), 43 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 0dd9c31ac..cec5221c7 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1962,15 +1962,25 @@ that the blast radius is not the author's own code. take `in` on parameters. And keep `RespContext` a `readonly struct`; making it a `ref struct` to "make it cheap" would make it unusable in the very methods it exists for. -2. **`RespRequest` must carry its own metadata first.** `Detach()` keeps bytes, lease, offset, length and - hash — and drops the key marks, the slot and the argument count. So inside - `IRespExecutor.Send(in RespRequest)` there is no way to ask which arguments were keys, and a cache - decorator cannot begin a fill. Widening the request (a `ulong`, two `int`s) is a prerequisite for the - executor-decorator model, not a refinement of it. - -3. **A retry executor needs the flags.** "Is this safe to resend" is the `CommandFlags` retry category, - and `Send` does not receive it. Same fix as (2), and they should land together or the decorator model - does not close. Retry otherwise fits well: it must hold the preformed payload across attempts, which is +2. ~~**`RespRequest` must carry its own metadata first.**~~ **Done.** `Detach(flags)` and + `AsLookupKey(flags)` now carry the key marks, slot, argument count and flags, and the request exposes + `KeyCount`/`TryGetKeys`/`GetKey`/`Slot`/`ArgCount`/`Flags`. The mark-resolution logic moved to statics + on `RespFrame` so both types answer identically rather than by duplicated code. + + Note the division of labour this exposes: **routing needs the slot and nothing else** — one `int`, + already folded during the write and gated on `ServerType == Cluster`, since CRC16 over every key is the + expensive part. Only *caching* needs the key marks, which are a few field writes and so are not gated. + The cheap thing is unconditional, the expensive thing is conditional; that asymmetry is deliberate. + + The fold still covers **every** key rather than just the first, because it is not only producing a + routing value: it detects cross-slot, which is a correctness check in cluster. First-key-only would + yield a plausible slot for a command that must be rejected outright. + + Identity deliberately ignores all of it: `Equals`/`GetHashCode` remain the rendered bytes alone, so two + callers issuing the same command with different `CommandFlags` share a cache entry. Pinned by a test. + +3. ~~**A retry executor needs the flags.**~~ **Done, with (2)** — `RespRequest.Flags` carries the retry + category. Retry otherwise fits well: it must hold the preformed payload across attempts, which is exactly what `RespRequest.TryRetain` is for. 4. **Decorator order is silent and load-bearing.** `cache(retry(raw))`: a hit must not traverse retry @@ -1981,6 +1991,11 @@ that the blast radius is not the author's own code. installing it is a `With` on the context. Behaviour composes in the executor chain, configuration composes on the context. +**`GetDatabase()` becomes the secondary API.** Long term the primary entry point returns the new root +interface rather than `IDatabase`; for now it can simply be `NewThing() => GetDatabase()`, since +`IDatabase` implements it. That keeps the transition a rename rather than a fork, and means the "last +break" is spent once at the interface rather than again at the entry point. + **Keep `IRespExecutor.Send` (the synchronous member).** Driving sync as `AsTask().GetAwaiter().GetResult()` blocks a pool thread for a whole round trip, which today's sync path deliberately avoids and which a large constituency depends on. Fine for a spike; but keeping the sync diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 59ebae39a..7172824aa 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -69,18 +69,18 @@ internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, /// caller tracking keys for invalidation would believe it had them all. Commands with that many keys /// are rare and large; callers should decline to cache such a frame. /// - public readonly int KeyCount + public readonly int KeyCount => KeyCountOf(_keyMarks); + + /// As , for a caller holding only the packed marks. + internal static int KeyCountOf(ulong keyMarks) { - get + if ((keyMarks & OverflowFlag) == 0) { - if ((_keyMarks & OverflowFlag) == 0) - { - if (_keyMarks == 0) return 0; - return ((_keyMarks >> SlotBits) & SlotMask) == 0 ? 1 : 2; - } - - return (_keyMarks & TruncatedFlag) != 0 ? -1 : PopCount(_keyMarks & ~OverflowFlag); + if (keyMarks == 0) return 0; + return ((keyMarks >> SlotBits) & SlotMask) == 0 ? 1 : 2; } + + return (keyMarks & TruncatedFlag) != 0 ? -1 : PopCount(keyMarks & ~OverflowFlag); } private static int PopCount(ulong value) @@ -108,24 +108,36 @@ private static int PopCount(ulong value) /// once per frame should cache the result. /// public readonly int TryGetKeys(scoped Span target) + => ResolveKeys(_buffer!, _start, _length, _keyMarks, target); + + /// + /// As , for a caller holding the buffer and marks rather than a frame - + /// which is how answers the same question after . + /// + internal static int ResolveKeys( + byte[] buffer, + int start, + int length, + ulong keyMarks, + scoped Span target) { - if ((_keyMarks & OverflowFlag) == 0) + if ((keyMarks & OverflowFlag) == 0) { var count = 0; - var a = (int)(_keyMarks & SlotMask); - var b = (int)((_keyMarks >> SlotBits) & SlotMask); + var a = (int)(keyMarks & SlotMask); + var b = (int)((keyMarks >> SlotBits) & SlotMask); var needed = (a != 0 ? 1 : 0) + (b != 0 ? 1 : 0); if (target.Length < needed) return -1; - if (a != 0) target[count++] = PayloadOf(a); - if (b != 0) target[count++] = PayloadOf(b); + if (a != 0) target[count++] = PayloadOf(buffer, a); + if (b != 0) target[count++] = PayloadOf(buffer, b); return count; } - if ((_keyMarks & TruncatedFlag) != 0) return -1; + if ((keyMarks & TruncatedFlag) != 0) return -1; - var bitmap = _keyMarks & ~OverflowFlag; + var bitmap = keyMarks & ~OverflowFlag; if (target.Length < PopCount(bitmap)) return -1; - return WalkKeys(bitmap, target); + return WalkKeys(buffer, start, length, bitmap, target); } /// @@ -136,12 +148,11 @@ public readonly int TryGetKeys(scoped Span target) /// skipping - no RespReader, no allocation. Argument 0 is the command, matching the indices /// the writer recorded. /// - private readonly int WalkKeys(ulong bitmap, scoped Span target) + private static int WalkKeys(byte[] buffer, int start, int length, ulong bitmap, scoped Span target) { - var buffer = _buffer!; - var end = _start + _length; + var end = start + length; - var i = _start; + var i = start; while (buffer[i] != (byte)'\n') i++; // past the '*N\r\n' header i++; @@ -149,20 +160,20 @@ private readonly int WalkKeys(ulong bitmap, scoped Span target) while (i < end) { var j = i + 1; // past the '$' - var length = 0; + var bulk = 0; while (buffer[j] != (byte)'\r') { - length = (length * 10) + (buffer[j] - (byte)'0'); + bulk = (bulk * 10) + (buffer[j] - (byte)'0'); j++; } var payload = j + 2; if (arg <= MaxBitmapArg && (bitmap & (1UL << arg)) != 0) { - target[count++] = new KeyRange(payload, length); + target[count++] = new KeyRange(payload, bulk); } - i = payload + length + 2; + i = payload + bulk + 2; arg++; } @@ -176,9 +187,8 @@ private readonly int WalkKeys(ulong bitmap, scoped Span target) /// Given the buffer-absolute offset of a fragment's '$', parse the self-describing length and return /// the payload range; no length needs to be stored alongside the offset. /// - private readonly KeyRange PayloadOf(int offset) + private static KeyRange PayloadOf(byte[] buffer, int offset) { - var buffer = _buffer!; int i = offset + 1, length = 0; while (buffer[i] != (byte)'\r') { @@ -208,11 +218,19 @@ private readonly KeyRange PayloadOf(int offset) /// the frame, so a copy taken earlier still holds the array reference and must not be disposed. /// /// - public RespRequest Detach() + public RespRequest Detach(CommandFlags flags = CommandFlags.None) { var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); _buffer = null; // ownership moves to the lease - return new RespRequest(buffer, RefCountedBuffer.Adopt(buffer, buffer.Length), _start, _length); + return new RespRequest( + buffer, + RefCountedBuffer.Adopt(buffer, buffer.Length), + _start, + _length, + _keyMarks, + Slot, + ArgCount, + flags); } /// @@ -232,10 +250,18 @@ public RespRequest Detach() /// when ownership is actually wanted. /// /// - public RespRequest AsLookupKey() + public RespRequest AsLookupKey(CommandFlags flags = CommandFlags.None) { var buffer = _buffer ?? throw new ObjectDisposedException(nameof(RespFrame)); - return new RespRequest(buffer, lease: null, _start, _length); + return new RespRequest( + buffer, + lease: null, + _start, + _length, + _keyMarks, + Slot, + ArgCount, + flags); } /// Return the underlying buffer to the pool; safe to call more than once. diff --git a/src/StackExchange.Redis/Interpolated/RespRequest.cs b/src/StackExchange.Redis/Interpolated/RespRequest.cs index 02024ccf2..5290deea2 100644 --- a/src/StackExchange.Redis/Interpolated/RespRequest.cs +++ b/src/StackExchange.Redis/Interpolated/RespRequest.cs @@ -42,15 +42,59 @@ namespace StackExchange.Redis.Interpolated private readonly int _length; private readonly int _hash; - internal RespRequest(byte[] array, RefCountedBuffer? lease, int offset, int length) + // carried over from the frame, because an executor decorator sees only a request. Routing needs the + // slot; a cache needs the key marks to register dependencies; retry and cacheability need the flags. + // Without these a decorator can ask nothing about what it is sending. + private readonly ulong _keyMarks; + + internal RespRequest( + byte[] array, + RefCountedBuffer? lease, + int offset, + int length, + ulong keyMarks = 0, + int slot = ServerSelectionStrategy.NoSlot, + int argCount = 0, + CommandFlags flags = CommandFlags.None) { _array = array; _lease = lease; _offset = offset; _length = length; _hash = RedisValue.GetHashCode(array.AsSpan(offset, length)); + _keyMarks = keyMarks; + Slot = slot; + ArgCount = argCount; + Flags = flags; } + /// The combined cluster slot; routing needs this and nothing else about the keys. + public int Slot { get; } + + /// The number of RESP arguments, including the command itself. + public int ArgCount { get; } + + /// + /// The command's flags: the retry category a retrying executor needs, and the caching gates. + /// + public CommandFlags Flags { get; } + + /// + /// How many arguments were keys, or -1 when the request cannot report them. + /// + /// + public int KeyCount => RespFrame.KeyCountOf(_keyMarks); + + /// Recover the key payloads; see . + /// Receives the ranges; size it from . + public int TryGetKeys(scoped Span target) + => _array is null ? -1 : RespFrame.ResolveKeys(_array, _offset, _length, _keyMarks, target); + + /// Resolve a against the underlying buffer. + /// The range to resolve. + public ReadOnlySpan GetKey(in KeyRange range) + => _array is null ? default : new(_array, range.Offset, range.Length); + /// Whether this key refers to anything; a default instance does not. public bool IsEmpty => _array is null; @@ -115,6 +159,11 @@ public bool TryRetain(out RespRequest retained) /// different arrays. Canonicality of the rendering is therefore a correctness property - see design /// doc section 6. /// + /// + /// Note what is NOT compared: slot, arg count, flags and key marks. Identity is the rendered bytes + /// and only the rendered bytes, because that is what the cache is keyed on - two callers issuing + /// the same command with different are asking the same question. + /// public bool Equals(RespRequest other) => _hash == other._hash && _length == other._length && Span.SequenceEqual(other.Span); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 4dce6c062..0e924263c 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -72,8 +72,8 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespFragment.RespFragment() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespFrame.ArgCount.get -> int -[SER010]StackExchange.Redis.Interpolated.RespFrame.AsLookupKey() -> StackExchange.Redis.Interpolated.RespRequest -[SER010]StackExchange.Redis.Interpolated.RespFrame.Detach() -> StackExchange.Redis.Interpolated.RespRequest +[SER010]StackExchange.Redis.Interpolated.RespFrame.AsLookupKey(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Interpolated.RespRequest +[SER010]StackExchange.Redis.Interpolated.RespFrame.Detach(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Interpolated.RespRequest [SER010]StackExchange.Redis.Interpolated.RespFrame.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespFrame.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespFrame.HasNoKeys.get -> bool @@ -98,13 +98,19 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespPayload.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespPayload.TryRetain() -> bool [SER010]StackExchange.Redis.Interpolated.RespRequest +[SER010]StackExchange.Redis.Interpolated.RespRequest.ArgCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespRequest.Equals(StackExchange.Redis.Interpolated.RespRequest other) -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest.Flags.get -> StackExchange.Redis.CommandFlags +[SER010]StackExchange.Redis.Interpolated.RespRequest.GetKey(in StackExchange.Redis.Interpolated.KeyRange range) -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespRequest.GetReader() -> RESPite.Messages.RespReader [SER010]StackExchange.Redis.Interpolated.RespRequest.IsEmpty.get -> bool [SER010]StackExchange.Redis.Interpolated.RespRequest.IsOwned.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespRequest.KeyCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.RespRequest() -> void +[SER010]StackExchange.Redis.Interpolated.RespRequest.Slot.get -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespRequest.TryGetKeys(scoped System.Span target) -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.TryRetain(out StackExchange.Redis.Interpolated.RespRequest retained) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 1cfdc4ad5..a9a570e78 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -723,6 +724,49 @@ public void RepliesWithNoContentElementAreRefused() Assert.Equal(0, cache.Count); } + [Fact] + public void ADetachedRequestCanStillAnswerForItself() + { + // the point of the widening: an executor decorator sees a RespRequest and nothing else, so the + // request has to carry what routing, retry and caching each need to ask + using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + using var request = frame.Detach(CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache); + + Assert.Equal(4, request.ArgCount); + Assert.Equal(CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache, request.Flags); + Assert.Equal(3, request.KeyCount); + + var ranges = new KeyRange[request.KeyCount]; + Assert.Equal(3, request.TryGetKeys(ranges)); + Assert.Equal( + new[] { "a", "b", "c" }, + ranges.Select(r => Encoding.UTF8.GetString(request.GetKey(r).ToArray())).ToArray()); + } + + [Fact] + public void RoutingNeedsOnlyTheSlotAndTheRequestCarriesIt() + { + var cluster = new RespContext(serverType: ServerType.Cluster); + using var frame = cluster.Execute($"{RedisCommand.GET}{(RedisKey)"{tag}:x"}"); + var slot = frame.Slot; + Assert.NotEqual(ServerSelectionStrategy.NoSlot, slot); + + using var request = frame.Detach(); + Assert.Equal(slot, request.Slot); // one int; no key marks involved in routing at all + } + + [Fact] + public void MetadataDoesNotAffectRequestIdentity() + { + using var plain = Get("abc").Detach(CommandFlags.CommandRetryReadOnly); + using var different = Get("abc").Detach(CommandFlags.CommandRetryNever | CommandFlags.NoClientCache); + + // identity is the rendered bytes and only the rendered bytes: two callers issuing the same command + // with different flags are asking the same question, so they must share a cache entry + Assert.Equal(plain, different); + Assert.Equal(plain.GetHashCode(), different.GetHashCode()); + } + private static string[] KeyStrings(in RespFrame frame) { var count = frame.KeyCount; From 04f5e8ef338df94484febaa760e5de5cf257db48 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 08:23:18 +0100 Subject: [PATCH 061/360] Prototype the context surface: target.Strings.Set/Get end to end 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. --- design/interpolated-resp-writer.md | 30 +++- .../Interpolated/RespClientCache.cs | 18 ++ .../Interpolated/RespContext.cs | 36 +++- .../Interpolated/RespExecutor.cs | 4 +- .../Interpolated/RespSurface.cs | 166 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 27 +++ .../RespSurfaceTests.cs | 139 +++++++++++++++ 7 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.cs create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index cec5221c7..b8fdb2ecc 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1954,9 +1954,37 @@ to `IDatabase` after the context exists spends the break for nothing. Worth writ worth an analyzer — this repo already gates hand-built fragments behind `SER011` on the same reasoning, that the blast radius is not the author's own code. +#### What the prototype found + +Built as `IRespTarget` + `RespStrings` + `RespSurface` (`RespSurfaceTests`), with a fake executor +underneath. `target.Strings.Set(key, value)` and `.Get(key)` work end to end, through the cache, with +`WithKeyPrefix` as a context clone and no per-method forwarding. + +- **Extension members compile on every target**, `net461` and `netstandard2.0` included. They are compiler + lowering, like the interpolated handler itself, so the down-level story that made §1 work holds here too. + This was the main risk and it is gone. +- **Each extension member costs TWO `PublicAPI` entries** — the `extension(...)` form *and* the lowered + static (`RespSurface.get_Strings(IRespTarget)`). So "extension members are free to add" is true for + source and binary compatibility, but not for API tracking: the surface still grows, and the lowered + names are part of it. Worth knowing before the surface is hundreds of commands. +- **A plain wrapper is enough.** `RespStrings` holds one `RespContext` field, which makes it + layout-identical by construction — the wrapper *is* the pun, enforced by the compiler, with no `Unsafe` + and no `ref readonly`. Both entry points work: `target.Strings` and `context.Strings`. +- **`Context` returning by value costs nothing visible** and keeps every command `async`-usable, settling + item (1) below. +- **A missing executor throws rather than silently doing nothing** — worth pinning early, because a + `default(RespContext)` is valid by design (§3.5) and would otherwise render a frame and drop it. + +**And one bug the prototype exposed.** `RefusedByFlags` was unreachable in real use: the orchestration +skips the cache entirely when flags forbid caching — it does not probe and then decline — so +`TryBeginFill` was never reached and never counted. A diagnostic that reads zero because nothing asks it +looks like evidence, which is worse than not having it. The flag decision now goes through +`cache.PermitsCaching(flags)`, so the cache observes every refusal without probing anything it has been +told to leave alone. + #### Four things to settle before building it -1. **`ref readonly` and `async` do not mix.** A `ref readonly` local cannot cross an `await`, and the +1. ~~**`ref readonly` and `async` do not mix.**~~ **Settled: by value.** A `ref readonly` local cannot cross an `await`, and the command surface is `ValueTask`-first — so the context is copied into the state machine anyway and the `ref` buys nothing on the only path that matters. At roughly four registers, copy it: return by value, take `in` on parameters. And keep `RespContext` a `readonly struct`; making it a `ref struct` to "make diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 563f13fe2..43ef64516 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -411,6 +411,24 @@ private static bool IsCacheableBehindAttributes(ReadOnlySpan response) return reader.TryMoveNext(checkError: false) && !reader.IsError; } + /// + /// As , but observed: a refusal is counted. + /// + /// + /// The orchestration skips the cache entirely for a command whose flags forbid it - it does not + /// probe and then decline - so + /// is never reached and could never count those. That made unreachable + /// in real use, which is worse than not having it: a diagnostic that reads zero because it is never + /// asked looks like evidence. Routing the decision through the cache fixes that without making the + /// cache probe things it has been told not to. + /// + internal bool PermitsCaching(CommandFlags flags) + { + if (IsCacheable(flags)) return true; + Interlocked.Increment(ref _refusedByFlags); + return false; + } + /// /// Whether the command's retry category permits caching at all. /// diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 3c4acbeed..1bdeec78d 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -35,7 +35,9 @@ internal RespContext( RedisChannel channelPrefix = default, int database = 0, ServerType serverType = ServerType.Standalone, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + IRespExecutor? executor = null, + RespClientCache? cache = null) { _commandMap = commandMap; _keyPrefix = keyPrefix; // normalise to bytes ONCE; the conversion can allocate for a string-backed key @@ -43,8 +45,20 @@ internal RespContext( Database = database; ServerType = serverType; CancellationToken = cancellationToken; + Executor = executor; + Cache = cache; } + /// Where commands composed from this context are sent; null if none is configured. + /// + /// Behaviour composes here - a retrying or caching executor is a decorator around an inner one - while + /// configuration composes on the context, via . They are not alternatives. + /// + public IRespExecutor? Executor { get; } + + /// The client-side cache to consult, or null for none. + public RespClientCache? Cache { get; } + private readonly CommandMap? _commandMap; /// @@ -82,12 +96,12 @@ public RespContext WithCancellationToken(CancellationToken cancellationToken) /// A copy of this context targeting a different database. /// The database index. public RespContext WithDatabase(int database) - => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken); + => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken, Executor, Cache); /// A copy of this context with a different server type. /// The server type. public RespContext WithServerType(ServerType serverType) - => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken); + => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken, Executor, Cache); /// /// Returns a context whose keys are prefixed. This is what replaces wrapping the database in a @@ -101,12 +115,24 @@ public RespContext WithKeyPrefix(RedisKey keyPrefix) ChannelPrefix, Database, ServerType, - CancellationToken); + CancellationToken, + Executor, + Cache); /// A copy of this context with a different channel prefix. /// The prefix to apply to channels. public RespContext WithChannelPrefix(RedisChannel channelPrefix) - => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken); + => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken, Executor, Cache); + + /// A copy of this context that sends through . + /// The executor to send through. + public RespContext WithExecutor(IRespExecutor? executor) + => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, executor, Cache); + + /// A copy of this context that consults . + /// The cache to consult, or null for none. + public RespContext WithCache(RespClientCache? cache) + => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, Executor, cache); /// /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into the diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 590ad5f5c..41d18240c 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -107,7 +107,7 @@ public static TResult Send( // NoClientCache suppresses the PROBE as well as the store: opting out must mean the caller does // not get a cached answer either, not merely that this reply is not kept - if (cache is not null && RespClientCache.IsCacheable(flags)) + if (cache is not null && cache.PermitsCaching(flags)) { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; @@ -174,7 +174,7 @@ public static ValueTask SendAsync( if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); - if (cache is not null && RespClientCache.IsCacheable(flags)) + if (cache is not null && cache.PermitsCaching(flags)) { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) { diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs new file mode 100644 index 000000000..86aac0ff6 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -0,0 +1,166 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The root of the context-based surface: one member, from which everything else + /// hangs as extension members. + /// + /// + /// + /// The point of having exactly one member is that it is the last addition to an interface. Once a + /// context is reachable, new commands - ours and other libraries' - are extension members over it, and + /// break nobody. See design notes section 9.4. + /// + /// + /// returns by value. A ref readonly would save a copy of roughly + /// four registers, and cost the ability to use the result in an async method - which is the only + /// kind of method this surface has. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public interface IRespTarget + { + /// The context commands are composed and sent through. + RespContext Context { get; } + } + + /// + /// EXPERIMENTAL SPIKE. The string-command group: target.Strings.Set(...). + /// + /// + /// + /// A plain wrapper over one field - deliberately NOT a reinterpret-cast of a + /// layout-compatible struct. Holding exactly one field of that type makes the layout identical by + /// construction, so the wrapper IS the pun, enforced by the compiler and with no Unsafe. A + /// by-value pun would copy the same bytes anyway; only a ref pun avoids the copy, and that + /// requires a stable address, which drags ref readonly and its lifetime rules into every caller + /// to save a few register moves ahead of a network round trip. + /// + /// + /// Not a ref struct, for the same reason is not: these have to survive + /// an await. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespStrings + { + private readonly RespContext _context; + + /// Group the string commands of a context. + /// The context to send through. + public RespStrings(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + /// EXPERIMENTAL SPIKE. Reply handlers for the prototype command surface. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespHandlers + { + /// Reads a bulk string reply as a ; null stays null. + public static IRespHandler Value { get; } = new ValueHandler(); + + /// Reads a simple-string reply as success. + public static IRespHandler Ok { get; } = new OkHandler(); + + private sealed class ValueHandler : IRespHandler + { + public RedisValue Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.IsNull ? RedisValue.Null : reader.ReadRedisValue(); + } + } + + private sealed class OkHandler : IRespHandler + { + public bool Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.Is("OK"u8); + } + } + } + + /// + /// EXPERIMENTAL SPIKE. The command surface, as extension members. + /// + /// + /// This is the shape the whole design exists to enable: ctx.Strings.Set(key, value) reads like a + /// built-in method, groups the surface the way Redis documents itself, and is reachable by any library - + /// including one that is not this one - without a wrapper interface or a forked surface. + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespSurface + { + extension(IRespTarget target) + { + /// The string commands. + public RespStrings Strings => new(target.Context); + } + + extension(in RespContext context) + { + /// The string commands. + public RespStrings Strings => new(context); + } + + extension(RespStrings strings) + { + /// GET. + /// The key to read. + /// Command flags. + public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags.CommandRetryReadOnly) + { + var ctx = strings.Context; + var frame = ctx.Execute($"{RedisCommand.GET}{key}"); + return ctx.SendAsync(ref frame, RespHandlers.Value, flags); + } + + /// SET. + /// The key to write. + /// The value to write. + /// Command flags. + public ValueTask Set( + RedisKey key, + RedisValue value, + CommandFlags flags = CommandFlags.CommandRetryWriteLastWins) + { + var ctx = strings.Context; + var frame = ctx.Execute($"{RedisCommand.SET}{key}{value}"); + return ctx.SendAsync(ref frame, RespHandlers.Ok, flags); + } + } + + extension(in RespContext context) + { + /// Send a rendered request through this context's executor and cache. + /// What parsing the reply produces. + /// The rendered request; consumed on every path. + /// Turns the reply into a result. + /// Command flags. + public ValueTask SendAsync( + ref RespFrame request, + IRespHandler handler, + CommandFlags flags) + { + var executor = context.Executor; + if (executor is null) + { + request.Dispose(); + throw new InvalidOperationException("No executor is configured on this context."); + } + + return executor.SendAsync(ref request, handler, flags, context.Cache, context.CancellationToken); + } + } + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 0e924263c..6ccb1133a 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -6,6 +6,8 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.IRespExecutor.SendAsync(StackExchange.Redis.Interpolated.RespRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult +[SER010]StackExchange.Redis.Interpolated.IRespTarget +[SER010]StackExchange.Redis.Interpolated.IRespTarget.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.KeyRange [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange() -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void @@ -49,6 +51,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void [SER010]StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.Cache.get -> StackExchange.Redis.Interpolated.RespClientCache? [SER010]StackExchange.Redis.Interpolated.RespContext.CancellationToken.get -> System.Threading.CancellationToken [SER010]StackExchange.Redis.Interpolated.RespContext.ChannelPrefix.get -> StackExchange.Redis.RedisChannel [SER010]StackExchange.Redis.Interpolated.RespContext.CommandMap.get -> StackExchange.Redis.CommandMap! @@ -57,12 +60,15 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespContext.Database.get -> int [SER010]StackExchange.Redis.Interpolated.RespContext.Execute(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespContext.Execute(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespContext.Executor.get -> StackExchange.Redis.Interpolated.IRespExecutor? [SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void [SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType +[SER010]StackExchange.Redis.Interpolated.RespContext.WithCache(StackExchange.Redis.Interpolated.RespClientCache? cache) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithCancellationToken(System.Threading.CancellationToken cancellationToken) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithChannelPrefix(StackExchange.Redis.RedisChannel channelPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithExecutor(StackExchange.Redis.Interpolated.IRespExecutor? executor) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespExecutor @@ -91,6 +97,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Reset() -> void [SER010]StackExchange.Redis.Interpolated.RespFrameWriter.RespFrameWriter(int capacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Span.get -> System.ReadOnlySpan +[SER010]StackExchange.Redis.Interpolated.RespHandlers [SER010]StackExchange.Redis.Interpolated.RespPayload [SER010]StackExchange.Redis.Interpolated.RespPayload.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.GetReader() -> RESPite.Messages.RespReader @@ -112,11 +119,31 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespRequest.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespRequest.TryGetKeys(scoped System.Span target) -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.TryRetain(out StackExchange.Redis.Interpolated.RespRequest retained) -> bool +[SER010]StackExchange.Redis.Interpolated.RespStrings +[SER010]StackExchange.Redis.Interpolated.RespStrings.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespStrings.RespStrings() -> void +[SER010]StackExchange.Redis.Interpolated.RespStrings.RespStrings(in StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespSurface +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings) +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).SendAsync(ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Ok.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.SendAsync(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs new file mode 100644 index 000000000..1d31dee5f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The context-based surface: target.Strings.Set(...), with nothing but a fake executor underneath. +/// See design notes section 9.4. +/// +public class RespSurfaceTests +{ + /// Records what was sent and replies from a script. + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + /// A minimal root object, standing in for what IDatabase would become. + private sealed class FakeTarget(RespContext context) : IRespTarget + { + public RespContext Context { get; } = context; + } + + private static FakeTarget Target(FakeExecutor executor, RespClientCache? cache = null) + => new(new RespContext().WithExecutor(executor).WithCache(cache)); + + [Fact] + public async Task SetAndGetThroughTheGroupedSurface() + { + var executor = new FakeExecutor("+OK\r\n", "$5\r\nhello\r\n"); + var target = Target(executor); + + Assert.True(await target.Strings.Set("mykey", "hello")); + Assert.Equal("hello", await target.Strings.Get("mykey")); + + Assert.Equal( + new[] { "*3|$3|SET|$5|mykey|$5|hello|", "*2|$3|GET|$5|mykey|" }, + executor.Sent); + } + + [Fact] + public async Task NullRepliesSurfaceAsRedisValueNull() + { + var target = Target(new FakeExecutor("$-1\r\n")); + Assert.True((await target.Strings.Get("missing")).IsNull); + } + + [Fact] + public async Task TheContextItselfIsAlsoAnEntryPoint() + { + // both shapes exist: from the root object, and from a context someone already holds + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var ctx = new RespContext().WithExecutor(executor); + Assert.Equal("hello", await ctx.Strings.Get("mykey")); + } + + [Fact] + public async Task WithKeyPrefixIsJustAContextClone() + { + var executor = new FakeExecutor("+OK\r\n"); + var target = Target(executor); + + // this is the whole of KeyPrefixedDatabase's write half - no per-method forwarding + var tenant = new FakeTarget(target.Context.WithKeyPrefix("t7:")); + await tenant.Strings.Set("user:1", "marc"); + + Assert.Equal("*3|$3|SET|$9|t7:user:1|$4|marc|", Assert.Single(executor.Sent)); + } + + [Fact] + public async Task TheCacheServesASecondReadWithoutSending() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var target = Target(executor, cache); + + Assert.Equal("hello", await target.Strings.Get("mykey")); + Assert.Equal("hello", await target.Strings.Get("mykey")); + Assert.Single(executor.Sent); // the second read never reached the executor + + cache.OnInvalidate(Encoding.UTF8.GetBytes("mykey")); + Assert.Equal("hello", await target.Strings.Get("mykey")); + Assert.Equal(2, executor.Sent.Count); + } + + [Fact] + public async Task WritesAreNotCached() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("+OK\r\n"); + var target = Target(executor, cache); + + await target.Strings.Set("mykey", "hello"); + await target.Strings.Set("mykey", "hello"); + + // SET defaults to a write retry category, which the flag gate rejects - so both were sent + Assert.Equal(2, executor.Sent.Count); + Assert.Equal(0, cache.Count); + Assert.Equal(2, cache.RefusedByFlags); + } + + [Fact] + public async Task NoClientCacheOptsASingleCallOut() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var target = Target(executor, cache); + + await target.Strings.Get("mykey"); + await target.Strings.Get("mykey", CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache); + + Assert.Equal(2, executor.Sent.Count); // the opted-out call did not read the cached entry + } + + [Fact] + public void MissingExecutorFailsLoudlyRatherThanSilently() + { + var target = new FakeTarget(new RespContext()); + Assert.Throws(() => target.Strings.Get("mykey")); + } +} From d324a01dfb1d1e8fd678588ca29fb993f9a7536f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 08:30:16 +0100 Subject: [PATCH 062/360] Orchestration takes the context; cache becomes a service, not a field 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. --- design/interpolated-resp-writer.md | 37 +++++++++++- .../Interpolated/RespContext.cs | 60 +++++++++++++++---- .../Interpolated/RespExecutor.cs | 37 ++++++------ .../Interpolated/RespSurface.cs | 23 ------- .../PublicAPI/PublicAPI.Unshipped.txt | 8 +-- .../RespClientCacheTests.cs | 39 ++++++------ 6 files changed, 128 insertions(+), 76 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index b8fdb2ecc..e0acb5615 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2015,9 +2015,40 @@ told to leave alone. logic, and a retry must not re-probe a cache it already missed. Nothing in the type system says so, so it wants a test. -**Cache and retry are not an either/or between "executor" and "context".** The decorator *is* an executor; -installing it is a `With` on the context. Behaviour composes in the executor chain, configuration composes -on the context. +#### Why the cache is not an executor decorator + +Tempting, because `Send`'s `cache` parameter and `RespContext.Cache` would both vanish. Two reasons not to: + +- **The executor contract deals in OWNED requests** — it has to, because a backlog or resend may need the + bytes past the call, which is what the reference count is for. A cache decorator therefore receives an + already-detached request, so **every call pays for ownership, including hits** — the 48 bytes that + `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, and minting a lease from it would give two owners + that both return it to the pool. +- **It would pin the executor to returning raw bytes forever.** The cache stores blobs, so a caching + decorator in the chain forecloses any later move toward executors that return processed results. + +So the layering is deliberate: the **frame level** decides whether to form and send at all — probing, +generation capture, ownership transfer — and the **executor chain** operates on a formed, owned request. +Retry belongs in the chain because it resends the same bytes; caching belongs above it because it decides +whether bytes are needed. + +#### Services, not fields + +The orchestration takes **`in RespContext`** rather than a cache and a cancellation token. A cache is then +just a service the context happens to carry, and `WithCache` is sugar over `WithServices`. + +The slot follows `RespReader`'s: one `object?` that either *is* the requested service — the common case, a +type test — or is an `IServiceProvider` for things the context knows nothing about. That buys +**extensibility with no new fields**, so a capability arriving later costs no API change and no growth in +the struct. Given the whole point of §9.4 is to stop adding members, adding a member per capability would +have been a poor start. + +The executor stays a real field: it is required on every call, where the cache is optional. + +**Cache and retry are still not an either/or between "executor" and "context".** A retry decorator *is* an +executor; installing it is a `With` on the context. Behaviour composes in the chain, configuration on the +context. **`GetDatabase()` becomes the secondary API.** Long term the primary entry point returns the new root interface rather than `IDatabase`; for now it can simply be `NewThing() => GetDatabase()`, since diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 1bdeec78d..558a8e6ee 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -37,7 +37,7 @@ internal RespContext( ServerType serverType = ServerType.Standalone, CancellationToken cancellationToken = default, IRespExecutor? executor = null, - RespClientCache? cache = null) + object? services = null) { _commandMap = commandMap; _keyPrefix = keyPrefix; // normalise to bytes ONCE; the conversion can allocate for a string-backed key @@ -46,7 +46,7 @@ internal RespContext( ServerType = serverType; CancellationToken = cancellationToken; Executor = executor; - Cache = cache; + _services = services; } /// Where commands composed from this context are sent; null if none is configured. @@ -56,8 +56,40 @@ internal RespContext( /// public IRespExecutor? Executor { get; } - /// The client-side cache to consult, or null for none. - public RespClientCache? Cache { get; } + private readonly object? _services; + + /// + /// Obtain a service attached to this context, if any. + /// + /// The service type. + /// The service, when found. + /// + /// One slot, which either is the requested service - the common case, a type test - or is an + /// able to supply services this context knows nothing about. Same + /// shape as RespReader's service slot, and for the same reason: it makes the context + /// extensible without new fields, so a capability that arrives later costs no API change and + /// no growth in the struct. A cache is simply the first such service. + /// + public bool TryGetService([NotNullWhen(true)] out T? service) + where T : class + { + switch (_services) + { + case T typed: + service = typed; + return true; + case IServiceProvider provider when provider.GetService(typeof(T)) is T resolved: + service = resolved; + return true; + default: + service = null; + return false; + } + } + + /// The client-side cache attached to this context, or null for none. + /// Convenience over ; the cache is not a field. + public RespClientCache? Cache => TryGetService(out var cache) ? cache : null; private readonly CommandMap? _commandMap; @@ -96,12 +128,12 @@ public RespContext WithCancellationToken(CancellationToken cancellationToken) /// A copy of this context targeting a different database. /// The database index. public RespContext WithDatabase(int database) - => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken, Executor, Cache); + => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken, Executor, _services); /// A copy of this context with a different server type. /// The server type. public RespContext WithServerType(ServerType serverType) - => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken, Executor, Cache); + => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken, Executor, _services); /// /// Returns a context whose keys are prefixed. This is what replaces wrapping the database in a @@ -117,22 +149,28 @@ public RespContext WithKeyPrefix(RedisKey keyPrefix) ServerType, CancellationToken, Executor, - Cache); + _services); /// A copy of this context with a different channel prefix. /// The prefix to apply to channels. public RespContext WithChannelPrefix(RedisChannel channelPrefix) - => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken, Executor, Cache); + => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken, Executor, _services); /// A copy of this context that sends through . /// The executor to send through. public RespContext WithExecutor(IRespExecutor? executor) - => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, executor, Cache); + => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, executor, _services); + + /// A copy of this context carrying . + /// The service, or an , or null. + public RespContext WithServices(object? services) + => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, Executor, services); /// A copy of this context that consults . /// The cache to consult, or null for none. - public RespContext WithCache(RespClientCache? cache) - => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, Executor, cache); + /// Sugar over ; "a context with a cache" is just a context whose + /// services include one. + public RespContext WithCache(RespClientCache? cache) => WithServices(cache); /// /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into the diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 41d18240c..6adc14f65 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -70,11 +70,10 @@ public interface IRespHandler public static class RespExecutor { /// - /// Send a request and parse the reply, optionally serving it from - and populating - - /// . + /// Send a request and parse the reply, optionally serving it from - and populating - the context's cache. /// /// What parsing the reply produces. - /// The executor to send through. + /// The context to send through; supplies the executor, cache and cancellation. /// The rendered request; consumed by this call on every path. /// Turns the reply into a result. /// @@ -82,7 +81,6 @@ public static class RespExecutor /// ; see /// . /// - /// The cache to consult, or null to bypass caching entirely. /// /// /// is deliberately not optional. Every IDatabase method in @@ -96,14 +94,14 @@ public static class RespExecutor /// and released in a finally; and the request is consumed on every path. /// public static TResult Send( - this IRespExecutor executor, + this in RespContext context, ref RespFrame request, IRespHandler handler, - CommandFlags flags, - RespClientCache? cache = null) + CommandFlags flags) { - if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); + var executor = context.Executor ?? ThrowNoExecutor(ref request); + var cache = context.Cache; // NoClientCache suppresses the PROBE as well as the store: opting out must mean the caller does // not get a cached answer either, not merely that this reply is not kept @@ -149,13 +147,11 @@ public static TResult Send( } } - /// - /// The executor to send through. + /// + /// The context to send through; supplies the executor, cache and cancellation. /// The rendered request; consumed by this call on every path. /// Turns the reply into a result. /// The command's flags; see the synchronous overload. - /// The cache to consult, or null to bypass caching entirely. - /// Cancels the send. /// /// Deliberately not an async method: async forbids ref parameters, and /// the frame has to be consumed by reference so the caller's copy cannot be used or disposed twice. @@ -164,15 +160,15 @@ public static TResult Send( /// state machine, no Task. /// public static ValueTask SendAsync( - this IRespExecutor executor, + this in RespContext context, ref RespFrame request, IRespHandler handler, - CommandFlags flags, - RespClientCache? cache = null, - CancellationToken cancellationToken = default) + CommandFlags flags) { - if (executor is null) throw new ArgumentNullException(nameof(executor)); if (handler is null) throw new ArgumentNullException(nameof(handler)); + var executor = context.Executor ?? ThrowNoExecutor(ref request); + var cache = context.Cache; + var cancellationToken = context.CancellationToken; if (cache is not null && cache.PermitsCaching(flags)) { @@ -190,6 +186,13 @@ public static ValueTask SendAsync( return AwaitUncached(executor, request.Detach(), handler, cancellationToken); } + [DoesNotReturn] + private static IRespExecutor ThrowNoExecutor(ref RespFrame request) + { + request.Dispose(); + throw new InvalidOperationException("No executor is configured on this context."); + } + // the cache probe is identical for both, and borrows rather than detaching: on a HIT the request // never reaches the executor, so it never needs an owned lease private static bool TryServeFromCache( diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 86aac0ff6..3dfe16a92 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -139,28 +139,5 @@ public ValueTask Set( return ctx.SendAsync(ref frame, RespHandlers.Ok, flags); } } - - extension(in RespContext context) - { - /// Send a rendered request through this context's executor and cache. - /// What parsing the reply produces. - /// The rendered request; consumed on every path. - /// Turns the reply into a result. - /// Command flags. - public ValueTask SendAsync( - ref RespFrame request, - IRespHandler handler, - CommandFlags flags) - { - var executor = context.Executor; - if (executor is null) - { - request.Dispose(); - throw new InvalidOperationException("No executor is configured on this context."); - } - - return executor.SendAsync(ref request, handler, flags, context.Cache, context.CancellationToken); - } - } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6ccb1133a..78b04e52b 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -64,6 +64,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void [SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType +[SER010]StackExchange.Redis.Interpolated.RespContext.TryGetService(out T? service) -> bool [SER010]StackExchange.Redis.Interpolated.RespContext.WithCache(StackExchange.Redis.Interpolated.RespClientCache? cache) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithCancellationToken(System.Threading.CancellationToken cancellationToken) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithChannelPrefix(StackExchange.Redis.RedisChannel channelPrefix) -> StackExchange.Redis.Interpolated.RespContext @@ -71,6 +72,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespContext.WithExecutor(StackExchange.Redis.Interpolated.IRespExecutor? executor) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithServices(object? services) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespExecutor [SER010]StackExchange.Redis.Interpolated.RespFragment [SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int @@ -130,19 +132,17 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).SendAsync(ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null) -> TResult -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.IRespExecutor! executor, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.RespClientCache? cache = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Ok.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.SendAsync(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index a9a570e78..8c621a7a0 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -17,6 +17,9 @@ public class RespClientCacheTests { private static readonly RespContext Ctx = new(); + private static RespContext Via(IRespExecutor executor, RespClientCache? cache = null) + => new RespContext().WithExecutor(executor).WithCache(cache); + private static RespFrame Get(string key) => Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); @@ -334,7 +337,7 @@ public void SendRunsOnceThenServesFromCache() { // note: no 'using' on the frame and none on any payload - Send owns both var frame = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); + Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); } Assert.Equal(1, executor.Sent); @@ -347,10 +350,10 @@ public async Task SendAsyncMatchesSyncAndHitsCompleteSynchronously() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - Assert.Equal("$5|hello|", await executor.SendAsync(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); + Assert.Equal("$5|hello|", await Via(executor, cache).SendAsync(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); var hit = Get("abc"); - var pending = executor.SendAsync(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + var pending = Via(executor, cache).SendAsync(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); // a hit never touches the executor, so it must not build a state machine or a Task either Assert.True(pending.IsCompletedSuccessfully); @@ -365,7 +368,7 @@ public void ExecutorCanRetainTheRequestForAResend() var executor = new FakeExecutor("$5\r\nhello\r\n") { ParkRequests = true }; var frame = Get("abc"); - executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); // this is why the request is not a span: a backlog must be able to hold it past the call, and // still read it afterwards to resend @@ -381,7 +384,7 @@ public void CachedReplyIsSharedWithTheCallerNotCopied() var executor = new FakeExecutor("$5\r\nhello\r\n"); var frame = Get("abc"); - executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); using var probe = Get("abc"); Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); @@ -403,11 +406,11 @@ public void SendWithoutACacheIsTheSameCallShape() var executor = new FakeExecutor("$5\r\nhello\r\n"); var a = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref a, TextHandler.Instance, CommandFlags.None)); + Assert.Equal("$5|hello|", Via(executor).Send(ref a, TextHandler.Instance, CommandFlags.None)); // a null cache takes the same overload, so enabling caching is one argument, not a rewrite var b = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref b, TextHandler.Instance, CommandFlags.None, cache: null)); + Assert.Equal("$5|hello|", Via(executor).Send(ref b, TextHandler.Instance, CommandFlags.None)); Assert.Equal(2, executor.Sent); // no caching either way } @@ -422,7 +425,7 @@ public void SendStillAnswersWhenInvalidatedInFlight() var executor = new FakeExecutor("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); var frame = Get("abc"); - Assert.Equal("$5|hello|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); // still answered + Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); // still answered Assert.Equal(0, cache.Count); // ... not cached } @@ -435,7 +438,7 @@ public void SendAnswersEvenWhenTheFrameCannotBeCached() var frame = writer.Complete(); var executor = new FakeExecutor("$2\r\nok\r\n"); - Assert.Equal("$2|ok|", executor.Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache)); + Assert.Equal("$2|ok|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); Assert.Equal(0, cache.Count); // this path FALLS THROUGH to the uncached tail rather than duplicating it, so the frame must be @@ -450,10 +453,10 @@ public void SendLeavesNoReferenceBehindOnAnyPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - executor.Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); var hit = Get("abc"); - executor.Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); // exactly one reference survives - the cache entry's. If the helper leaked the caller's retain the // buffer would never return to the pool; if it over-released, the entry would be reading freed bytes @@ -471,15 +474,15 @@ public void SendConsumesTheFrameOnEveryPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - executor.Send(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); Assert.Throws(() => miss.AsLookupKey()); var hit = Get("abc"); - executor.Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); Assert.Throws(() => hit.AsLookupKey()); var uncached = Get("abc"); - executor.Send(ref uncached, TextHandler.Instance, CommandFlags.None); // the no-cache overload too + Via(executor).Send(ref uncached, TextHandler.Instance, CommandFlags.None); // the no-cache overload too Assert.Throws(() => uncached.AsLookupKey()); } @@ -563,19 +566,19 @@ public void NoClientCacheAlsoSuppressesServingFromCache() var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - executor.Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); Assert.Equal(1, executor.Sent); // opting out must mean the caller does not RECEIVE a cached answer either - not merely that this // reply is not kept. Otherwise "don't cache this" silently still serves stale data. var opted = Get("abc"); - executor.Send(ref opted, TextHandler.Instance, - CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache, cache); + Via(executor, cache).Send( + ref opted, TextHandler.Instance, CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache); Assert.Equal(2, executor.Sent); // ... and the entry is untouched for callers who did not opt out var normal = Get("abc"); - executor.Send(ref normal, TextHandler.Instance, CommandFlags.CommandRetryReadOnly, cache); + Via(executor, cache).Send(ref normal, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); Assert.Equal(2, executor.Sent); } From 2e0dd7846968f1c18908c385142609b571f95325 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 09:07:31 +0100 Subject: [PATCH 063/360] RespCommand: resolve a command name once, usable in either position '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. --- design/interpolated-resp-writer.md | 36 ++++ .../Interpolated/RespCommand.cs | 199 ++++++++++++++++++ .../Interpolated/RespCommandHandler.cs | 44 ++++ .../PublicAPI/PublicAPI.Unshipped.txt | 10 + .../RespCommandTests.cs | 120 +++++++++++ 5 files changed, 409 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespCommand.cs create mode 100644 tests/StackExchange.Redis.Tests/RespCommandTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e0acb5615..dd8260616 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -742,6 +742,42 @@ Same command, two cache entries, forever. Silent, so this is the highest-value a Keyspace prefixes fall out correctly for free — applied before the write, so tenants cannot collide. +### 5.3 `RespCommand`: resolved once, usable in either position + +`"FT.SEARCH".Command()` parses, validates and (optionally) frames a command name once. What it stores +depends on whether this library knows the name, and that split is a correctness requirement rather than an +optimisation: + +| | stored | why | +| --- | --- | --- | +| known (`"GET"`) | the `RedisCommand` | `CommandMap` is per-context and may rename **or disable** it; the map already holds the bytes | +| unknown, casual | the `string` | inline use encodes straight into the frame buffer - preforming would allocate an array to copy from and discard | +| unknown, `preform: true` | framed `byte[]` | a `static readonly` field pays once, then every use is a `memcpy` | + +**`preform` has no effect on a known command.** `CommandMap` stores every mapped name as a pre-framed RESP +fragment already — *"ready to throw directly into the stream"* — so the bytes are preformed per map, which +is the only place they can be: the map is what decides them. + +**Preforming an unknown command is safe**, and the reason is worth knowing: `CommandMap` is built by +walking the `RedisCommand` enum, so an override keyed on a name that does not parse — `FT.SEARCH`, +`JSON.GET` — is **silently ignored**. Nothing could rename it, so there is nothing to defer to. (That is +also a gap: module commands cannot be renamed or disabled client-side at all, while a server-side +`rename-command` on one works fine and is undetectable. Orthogonal, but more visible once module commands +are first-class.) + +A `u8` overload takes the name as bytes, so generated code and `static readonly` fields need no `string`: +`TryParseCI` matches on bytes directly, so even a known command needs no transcoding. + +**Position decides the meaning, and the bytes are identical.** First, it is the command; later, it is an +argument that names one — `$"{command}{Info}{target.Command()}"`. That second case is not a curiosity: a +server knows a renamed command **only by its new name**, so `COMMAND INFO HGET` returns nothing where +`HGET` was renamed, and you must pass the mapped spelling. Taking it from the map is the only way to get +it right, which is exactly what appending a `RespCommand` does. + +Validation happens at resolution, not on the wire: a name carrying CR, LF or a space would desynchronise +the connection for every subsequent command — the `SER011` hazard — so it is rejected once, where it is +free. The framing itself is ours, which is what distinguishes this from a hand-built fragment. + ### 6.1 Three incremental folds All O(1) state, all during the write, none needing a second pass: diff --git a/src/StackExchange.Redis/Interpolated/RespCommand.cs b/src/StackExchange.Redis/Interpolated/RespCommand.cs new file mode 100644 index 000000000..dfc7beb57 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespCommand.cs @@ -0,0 +1,199 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A command resolved once, for use as the first hole of an interpolated command. + /// + /// + /// + /// Holds a when the name is one this library knows, and pre-framed UTF-8 + /// bytes when it is not. The split matters, and it is not an optimisation: + /// + /// + /// + /// A known command must stay deferred, because is per-context and may + /// rename it or disable it - and "disabled" is signalled by the map returning nothing. Caching the + /// bytes at construction would silently bypass both. + /// + /// + /// An unknown name - a module command such as FT.SEARCH - can safely cache its bytes, + /// because cannot touch it. The map is built by walking the + /// RedisCommand enum, so an override keyed on a name that does not parse is silently ignored. + /// Nothing could rename it, so there is nothing to defer to. + /// + /// + /// + /// The point of resolving once is that the parse, the UTF-8 encoding, the framing and the + /// validation all happen at construction - typically a static readonly field - rather than + /// per call. A malformed name fails at type-initialisation, not on the wire. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespCommand + { + private readonly RedisCommand _command; + private readonly byte[]? _resp; // pre-framed '$len\r\nNAME\r\n'; unknown + preformed + private readonly string? _name; // unknown + encode-per-call + + internal RespCommand(RedisCommand command) + { + _command = command; + _resp = null; + _name = null; + } + + internal RespCommand(byte[] resp) + { + _command = RedisCommand.UNKNOWN; + _resp = resp; + _name = null; + } + + internal RespCommand(string name) + { + _command = RedisCommand.UNKNOWN; + _resp = null; + _name = name; + } + + /// The unresolved name, when this command encodes per call rather than being preformed. + internal string? Name => _name; + + /// Whether this is a command this library knows, and so one the command map can affect. + public bool IsKnown => _command != RedisCommand.UNKNOWN; + + /// The known command, or UNKNOWN. + internal RedisCommand Command => _command; + + /// Whether this instance names anything at all. + public bool IsEmpty => _command == RedisCommand.UNKNOWN && _resp is null && _name is null; + + /// Whether the RESP bytes were built once, rather than encoded on each use. + public bool IsPreformed => _resp is not null; + + /// The pre-framed RESP for this command, honouring when it applies. + /// The command map of the context being written. + internal ReadOnlySpan GetResp(CommandMap map) + { + if (_resp is not null) return _resp; // unknown, preformed: the map has no opinion on it + if (_name is not null) return default; // unknown, per-call: the caller encodes it + + var resp = map.GetResp(_command); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(_command); + return resp; + } + + /// + public override string ToString() => _name + ?? (_resp is null ? _command.ToString() : Encoding.UTF8.GetString(_resp).Replace("\r\n", "|")); + } + + /// EXPERIMENTAL SPIKE. Resolving a command name once. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespCommands + { + /// + /// Resolve a command name, parsing and validating once. + /// + /// + /// Validation happens here rather than per call, and rejects anything that would desynchronise the + /// connection if it reached the wire - which is the whole reason hand-built fragments are gated + /// behind SER011. Here the library does the framing, so the caller cannot get it wrong; only + /// the name is theirs, and it is checked. + /// + /// The command name, for example "GET" or "FT.SEARCH". + /// + /// Whether to build the RESP bytes now rather than encoding on each use. false - the default - + /// suits casual, inline use; true suits a static readonly field. + /// + /// + /// + /// The flag is about when the encode happens, and it is a real trade rather than a free win. + /// Preforming inline would allocate a byte[] that is copied from once and discarded; leaving + /// the name as a string lets the writer encode straight into the frame buffer, so the casual + /// path allocates nothing extra. A field resolved once wants the opposite, and then every use is a + /// memcpy. + /// + /// + /// It has no effect on a known command. Those resolve through , which + /// already stores every mapped name as a pre-framed RESP fragment - "ready to throw directly into + /// the stream" - so the bytes are preformed already, per map, which is the only place they *can* be + /// preformed: the map is what decides them. + /// + /// + public static RespCommand Command(this string name, bool preform = false) + { + if (string.IsNullOrEmpty(name)) throw new ArgumentException("A command name is required.", nameof(name)); + + foreach (var c in name) Validate(c, name); + + if (RedisCommandMetadata.TryParseCI(name.AsSpan(), out var parsed) && parsed != RedisCommand.UNKNOWN) + { + // deferred: the context's command map may rename or disable it, and already holds the bytes + return new RespCommand(parsed); + } + + if (!preform) return new RespCommand(name); + + var payload = Encoding.UTF8.GetByteCount(name); + var resp = Frame(payload, out var offset); + Encoding.UTF8.GetBytes(name, 0, name.Length, resp, offset); + return new RespCommand(resp); + } + + /// + /// Resolve a command name held as UTF-8 - typically a u8 literal - so no string is + /// involved at any point. + /// + /// The command name as UTF-8, for example "FT.SEARCH"u8. + /// + /// The lower-level entry point, for generated code and for callers who already hold bytes. + /// matches on + /// bytes directly, so this needs no transcoding even for known commands. Note this takes the command + /// name, not framed RESP: the framing is ours to get right, which is what separates this from + /// a hand-built RespFragment and its SER011 gate. + /// + public static RespCommand Command(this ReadOnlySpan name) + { + if (name.IsEmpty) throw new ArgumentException("A command name is required.", nameof(name)); + + foreach (var b in name) Validate((char)b, null); + + if (RedisCommandMetadata.TryParseCI(name, out var parsed) && parsed != RedisCommand.UNKNOWN) + { + return new RespCommand(parsed); + } + + var resp = Frame(name.Length, out var offset); + name.CopyTo(resp.AsSpan(offset)); + return new RespCommand(resp); + } + + private static void Validate(char c, string? name) + { + if (c > ' ' && c <= '~') return; + + // a CR, LF or space reaching the wire desynchronises the connection for every command that + // follows - the SER011 hazard - so it is rejected here, once, rather than risked per call + throw new ArgumentException( + $"Command names must be printable ASCII without whitespace; got '{name ?? ""}'.", + nameof(name)); + } + + // '$len\r\n' ... '\r\n', with the payload left to the caller + private static byte[] Frame(int payload, out int offset) + { + var header = Encoding.ASCII.GetBytes($"${payload}\r\n"); + var resp = new byte[header.Length + payload + 2]; + header.CopyTo(resp, 0); + offset = header.Length; + resp[resp.Length - 2] = (byte)'\r'; // not ^2: System.Index does not exist down-level + resp[resp.Length - 1] = (byte)'\n'; + return resp; + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index a5915abd1..c2f7ebd9d 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -178,6 +178,50 @@ internal void AppendFormatted(RedisCommand value) _argIndex++; } + /// Append a resolved command name. + /// The command, from . + /// + /// + /// Position decides the meaning, and the bytes are the same either way. First, it is the + /// command; later, it is an argument that happens to name a command - which is what + /// COMMAND INFO <name>, COMMAND DOCS and ACL rules need. + /// + /// + /// Either way it resolves through this context's , and that is not merely + /// tidy: the server knows a renamed command only by its new name. COMMAND INFO HGET + /// returns nothing on a server where HGET was renamed - you have to ask for the mapped name, + /// and the reply then reports the canonical one. Passing the mapped name is therefore the only + /// thing that works, and taking it from the map is the only way to get it. + /// + /// + public void AppendFormatted(RespCommand value) + { + if (value.IsEmpty) throw new ArgumentException("No command was supplied.", nameof(value)); + + // resolution happens HERE, not at construction: a known command still has to go through this + // context's map, which may rename or disable it + var resp = value.GetResp(_context.CommandMap); + if (resp.IsEmpty) + { + // an unknown command kept as a name: encode straight into the frame, no intermediate array + var name = value.Name!; + var nameBytes = Encoding.UTF8.GetByteCount(name); + WriteBulk(nameBytes, out var payloadOffset); + Encoding.UTF8.GetBytes(name, 0, name.Length, _buffer, _offset + payloadOffset); + CommitBulk(payloadOffset, nameBytes); + } + else + { + Ensure(resp.Length); + resp.CopyTo(_buffer.AsSpan(_offset)); + _offset += resp.Length; + } + + _hasCommand = true; // whether it was the command or merely the first thing written + _args++; + _argIndex++; + } + /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. /// The key to append. public void AppendFormatted(RedisKey value) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 78b04e52b..5c9d86717 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -39,7 +39,13 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, StackExchange.Redis.Interpolated.RespPayload! response) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool +[SER010]StackExchange.Redis.Interpolated.RespCommand +[SER010]StackExchange.Redis.Interpolated.RespCommand.IsEmpty.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespCommand.IsKnown.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespCommand.IsPreformed.get -> bool +[SER010]StackExchange.Redis.Interpolated.RespCommand.RespCommand() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void @@ -50,6 +56,7 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommands [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.Cache.get -> StackExchange.Redis.Interpolated.RespClientCache? [SER010]StackExchange.Redis.Interpolated.RespContext.CancellationToken.get -> System.Threading.CancellationToken @@ -133,9 +140,12 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings +[SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! +[SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this System.ReadOnlySpan name) -> StackExchange.Redis.Interpolated.RespCommand +[SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this string! name, bool preform = false) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment diff --git a/tests/StackExchange.Redis.Tests/RespCommandTests.cs b/tests/StackExchange.Redis.Tests/RespCommandTests.cs new file mode 100644 index 000000000..540f3f545 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCommandTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// RespCommand: a command name resolved once, usable as the command or as an argument naming one. +/// +public class RespCommandTests +{ + private static string Text(in RespFrame frame) => + Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + [Fact] + public void KnownCommandsStayDeferredSoTheMapStillApplies() + { + var get = "GET".Command(); + Assert.True(get.IsKnown); + Assert.False(get.IsPreformed); // the map holds the bytes, and only the map can + + var renamed = CommandMap.Create(new Dictionary { ["GET"] = "FETCH" }); + using var frame = new RespContext(renamed).Execute($"{get}{(RedisKey)"k"}"); + Assert.Equal("*2|$5|FETCH|$1|k|", Text(frame)); + } + + [Fact] + public void PreformingAKnownCommandWouldNotBypassTheMap() + { + // the flag is about WHEN the encode happens, not about skipping the map; a known command ignores it + Assert.False("GET".Command(preform: true).IsPreformed); + + var disabled = CommandMap.Create(new HashSet { "GET" }, available: false); + var ctx = new RespContext(disabled); + Assert.Throws(() => + { + using var frame = ctx.Execute($"{"GET".Command(preform: true)}{(RedisKey)"k"}"); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnknownCommandsRenderIdenticallyEitherWay(bool preform) + { + var search = "FT.SEARCH".Command(preform); + Assert.False(search.IsKnown); + Assert.Equal(preform, search.IsPreformed); + + using var frame = new RespContext().Execute($"{search}{(RedisValue)"idx"}"); + Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); + } + + [Fact] + public void UnknownCommandsAreUnaffectedByTheCommandMap() + { + // CommandMap is built by walking the RedisCommand enum, so an override on a name it cannot parse is + // silently ignored - which is why preforming a module command is safe + var renamed = CommandMap.Create(new Dictionary { ["FT.SEARCH"] = "FT.SRCH" }); + using var frame = new RespContext(renamed).Execute($"{"FT.SEARCH".Command()}{(RedisValue)"idx"}"); + Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); + } + + [Fact] + public void ACommandCanAlsoBeAnArgumentNamingACommand() + { + // COMMAND INFO : the server knows a renamed command ONLY by its new name, so the argument has + // to be the mapped spelling - taking it from the map is the only way to get that right + var renamed = CommandMap.Create(new Dictionary { ["HGET"] = "HASHGET" }); + var ctx = new RespContext(renamed); + + using var frame = ctx.Execute($"{"COMMAND".Command()}{RespLiterals.Info}{"HGET".Command()}"); + Assert.Equal("*3|$7|COMMAND|$4|INFO|$7|HASHGET|", Text(frame)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("GET KEY")] + [InlineData("GET\r\nEVIL")] + [InlineData("GET\n")] + public void MalformedNamesAreRejectedAtResolutionNotOnTheWire(string name) + { + // a CR or LF reaching the stream desynchronises every subsequent command; this is checked once, + // where it costs nothing, rather than risked per call + Assert.ThrowsAny(() => name.Command()); + } + + [Fact] + public void Utf8LiteralsNeedNoStringAtAll() + { + var fromString = "FT.SEARCH".Command(preform: true); + var fromBytes = "FT.SEARCH"u8.Command(); + + Assert.False(fromBytes.IsKnown); + using var a = new RespContext().Execute($"{fromString}{(RedisValue)"idx"}"); + using var b = new RespContext().Execute($"{fromBytes}{(RedisValue)"idx"}"); + Assert.Equal(Text(a), Text(b)); + } + + [Fact] + public void Utf8LiteralsAlsoResolveKnownCommandsThroughTheMap() + { + // TryParseCI matches on bytes directly, so a u8 literal needs no transcoding even when known + Assert.True("GET"u8.Command().IsKnown); + + var renamed = CommandMap.Create(new Dictionary { ["GET"] = "FETCH" }); + using var frame = new RespContext(renamed).Execute($"{"GET"u8.Command()}{(RedisKey)"k"}"); + Assert.Equal("*2|$5|FETCH|$1|k|", Text(frame)); + } + + internal static partial class RespLiterals + { +#pragma warning disable SER011 // stands in for the generator + internal static RespFragment Info => new("$4\r\nINFO\r\n"u8); +#pragma warning restore SER011 + } +} From a892bf0e81038f8d304f1ca1814927615f9df7b1 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 09:17:32 +0100 Subject: [PATCH 064/360] Literals become arguments; SER309 becomes a warning 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) 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. --- design/interpolated-resp-writer.md | 40 +++++- docs/rules/SER309.md | 6 + .../AnalyzerReleases.Unshipped.md | 2 +- eng/StackExchange.Redis.Build/Diagnostics.cs | 8 +- .../Interpolated/RespCommandHandler.cs | 126 +++++++++++++++--- .../StackExchange.Redis.Build.Tests/SER309.cs | 14 +- .../SER309CodeFix.cs | 14 +- .../InterpolatedLiteralCommandTests.cs | 126 ++++++++++++++++++ .../InterpolatedWriterUnitTests.cs | 22 +-- 9 files changed, 309 insertions(+), 49 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index dd8260616..a20fd408d 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -76,7 +76,45 @@ support, and down-level the attribute is inert, silently giving a one-element st ## 2. Shape -### 2.1 Literals are rejected, except a single space +### 2.1 Literals become arguments (was: rejected, except a single space) + +> **Superseded.** Literal text is no longer discarded, and SER309 is a **warning**, not an error. What +> follows records why rejection looked right first; the reasoning that replaced it is here. + +**What changed.** `AppendLiteral` tokenizes on whitespace. If nothing has been written yet, the first +token is the **command** - parsed against the known set, mapped through `CommandMap`, framed verbatim if +unrecognised. Every later token is an ordinary **value** argument, UTF-8 encoded straight into the frame. +Whitespace-only literals still contribute nothing, so `$"{cmd} {key} {value}"` is unchanged. + +So `$"SET {key} {value}"` renders byte-identically to `$"{RedisCommand.SET}{key}{value}"`, and +`$"COMMAND INFO {name.Command()}"` works. + +**Why this is better than rejecting.** The rejected form did exactly what it looked like; refusing to +compile it bought correctness we did not actually need. Working-but-slower beats not-working, and the +warning still points at the faster spelling. + +**Splitting on whitespace gets container commands right for free.** `$"CONFIG GET {name}"` yields three +arguments, with `CONFIG` mapped and `GET` not - which is precisely how `CommandMap` behaves, since it maps +container verbs only. That was not designed for; it fell out. + +**What it costs, and what it does not.** + +- Each token is parsed and encoded **per call**, where a `[Resp]` fragment or a `RespCommand` resolves + once. That is the whole content of the warning. +- `AppendLiteral` fast-paths empty and a single space before entering the tokenizer, so the recommended + spelling pays nothing for the readable one existing. The tokenizer is `[MethodImpl(NoInlining)]`, the + same split as `MessageWriter`'s fallbacks and for the same codegen reason. +- Nothing allocates: the split is index arithmetic over the literal, and the encode is pointer-based + straight into the frame buffer - `Encoding.GetByteCount(ReadOnlySpan)` does not exist on + netstandard2.0 or net461, but the `char*` overloads do. +- **A literal token is never a key.** Key-ness comes from the hole type, so routing and invalidation are + unaffected by any of this. +- `*N` is no longer derivable from `formattedCount` for this form, which is fine: the count was only ever + an optimisation hint, since `Compose` plus `AppendFormatted` already defeat it. + +--- + +#### Original reasoning: literals rejected, except a single space Every part of the command must be a hole, with one exception: a **single space**, which is discarded. diff --git a/docs/rules/SER309.md b/docs/rules/SER309.md index 1037acebb..07ad9cd05 100644 --- a/docs/rules/SER309.md +++ b/docs/rules/SER309.md @@ -1,5 +1,11 @@ # SER309: literal text in a RESP command is discarded, not sent +> **Updated:** literal text is no longer discarded, and this is now a **warning**. Literals +> become arguments - whitespace-separated, with a leading token taken as the command - so +> `$"SET {key} {value}"` works and matches `$"{RedisCommand.SET}{key}{value}"` byte for byte. The rule +> remains because each token is parsed and encoded on *every call*, where a `[Resp]` fragment or a +> `.Command()` field resolves once. + Only interpolation **holes** become RESP arguments. Literal text between them is thrown away, so a command written with inline tokens silently omits them. diff --git a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md index 843154d08..a739fdd99 100644 --- a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md +++ b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md @@ -15,5 +15,5 @@ SER305 | Usage | Error | QueuedResultAnalyzer: waiting for a command queu SER306 | Usage | Warning | QueuedResultAnalyzer: waiting for a fire-and-forget result reads the default value rather than the server's answer SER307 | Usage | Warning | QueuedResultAnalyzer: blocking on a redis call instead of awaiting it, which ties up a thread-pool thread while the reply needs one of its own SER308 | Usage | Warning | QueuedResultAnalyzer: calling the library's own Wait/WaitAll/TryWait helpers, which block the calling thread -SER309 | Usage | Error | RespInterpolationAnalyzer: literal text in a RESP interpolated command is discarded rather than sent as an argument +SER309 | Usage | Warning | RespInterpolationAnalyzer: literal text in a RESP interpolated command is parsed and encoded on every call, where a fragment or resolved command is prepared once SER351 | Build | Warning | RespFragmentGenerator: a [Resp] declaration that cannot be implemented, which would otherwise be skipped silently diff --git a/eng/StackExchange.Redis.Build/Diagnostics.cs b/eng/StackExchange.Redis.Build/Diagnostics.cs index d36011971..009948af7 100644 --- a/eng/StackExchange.Redis.Build/Diagnostics.cs +++ b/eng/StackExchange.Redis.Build/Diagnostics.cs @@ -327,12 +327,12 @@ internal static class Diagnostics /// public static readonly DiagnosticDescriptor RespLiteralNotSent = new( id: "SER309", - title: "Literal text in a RESP command is discarded, not sent", - messageFormat: "Literal text \"{0}\" is discarded rather than sent as an argument; declare it as a [Resp] fragment and use a hole, or delete it", + title: "Literal text in a RESP command is resolved on every call", + messageFormat: "Literal text \"{0}\" is parsed and encoded on every call; resolve it once - a [Resp] fragment for a token, or a .Command() field for a command", category: UsageCategory, - defaultSeverity: DiagnosticSeverity.Error, + defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Only interpolation holes become RESP arguments; literal text between them is discarded, so a command written with inline tokens silently omits them. A single space is permitted as a separator.", + description: "Literal text becomes RESP arguments - whitespace-separated, with a leading token taken as the command - so the result is correct, but each token is parsed and UTF-8 encoded on every call where a declared fragment or a resolved command is prepared once. Whitespace-only literals are separators and cost nothing.", helpLinkUri: HelpLink("SER309")); /// diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index c2f7ebd9d..95662c0c6 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using RESPite; @@ -135,32 +136,119 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con } /// - /// Literal text is rejected, with one exception: a single space, which is discarded. That keeps - /// $"{RedisCommand.SET} {key} {value}" readable - it mirrors how the command is written - /// everywhere else - without the space becoming an argument. + /// Literal text becomes arguments: whitespace-separated tokens, with the first one - if nothing has + /// been written yet - taken as the command. /// /// - /// Rejecting literals is what makes the compiler-supplied formattedCount the argument count, - /// so the *N header can be a compile-time constant. A discarded space does not affect that: - /// spaces are literal segments, not holes. See design/interpolated-resp-writer.md section 2.1. /// - /// This is a runtime check; the analyzer is expected to catch it at build time, which it must, since - /// two spaces look exactly like one. + /// So $"SET {key} {value}" and $"{RedisCommand.SET}{key}{value}" produce the same + /// frame, and $"CONFIG GET {name}" produces three arguments. Splitting on whitespace is what + /// makes container commands come out right for free: CONFIG is the command and goes through + /// the command map, while GET is an ordinary argument and does not - which is exactly how + /// works, since it maps container verbs only. + /// + /// + /// A literal that is only whitespace contributes nothing, so the spaces in + /// $"{cmd} {key} {value}" are still just separators. + /// + /// + /// This is the slow way to say it, and the analyzer says so - a warning, not an error, + /// because the result is correct, merely suboptimal. Each token is parsed and encoded on every call, + /// where a or a RespFragment resolves once. The fixer promotes + /// literals to those. Working-but-slower is the right default here: the alternative was rejecting + /// code that does exactly what it looks like. + /// + /// + /// Nothing here allocates: the split is span slicing over the literal, and the encode goes straight + /// into the frame buffer. /// /// + /// The literal text. public void AppendLiteral(string value) { - // Deliberately empty, with no check: the JIT eliminates the call entirely. - // - // Enforcement belongs to the analyzer, which reports literal text as an ERROR and offers a fix - // rewriting it to a declared fragment. A runtime check would buy nothing the analyzer does not, - // because the failure mode here is benign in the way that matters: a discarded literal produces - // a WELL-FORMED frame with an argument missing. The server errors, or does the wrong thing, and - // the connection is unaffected - literals never contributed to *N, so the header stays correct. - // - // Contrast RespFragment (SER011), where bad bytes desync the connection for every subsequent - // command. Guard strength is proportional to blast radius: analyzer error here, analyzer plus a - // generator-emitted #error there. + // The two cases on the recommended path, and by far the most common: nothing at all, and the + // single space of $"{cmd} {key} {value}". Both contribute no arguments, so neither is worth + // entering the tokenizer for - and keeping them here means the preferred spelling pays nothing + // for the existence of the readable one. + if (value is null || value.Length == 0) return; + if (value.Length == 1 && value[0] == ' ') return; + + AppendLiteralSlow(value); + } + + /// Tokenize literal text into a command and/or arguments. + /// + /// Separate and not inlined: this is the uncommon path, and inlining a loop plus an encoder into + /// would change codegen for the fast one - the same split, for the same + /// reason, as the fallbacks in MessageWriter. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendLiteralSlow(string value) + { + var pos = 0; + while (pos < value.Length) + { + while (pos < value.Length && IsSeparator(value[pos])) pos++; + if (pos >= value.Length) return; + + var tokenStart = pos; + while (pos < value.Length && !IsSeparator(value[pos])) pos++; + AppendToken(value, tokenStart, pos - tokenStart); + } + + static bool IsSeparator(char c) => c is ' ' or '\t' or '\r' or '\n'; + } + + /// One whitespace-separated run of literal text: the command if first, else an argument. + private void AppendToken(string value, int start, int length) + { + if (!_hasCommand) + { + // first thing written: this is the command. A name we know goes through the map - which may + // rename or disable it; anything else is framed verbatim, as Execute(string, ...) already does + if (RedisCommandMetadata.TryParseCI(value.AsSpan(start, length), out var parsed) + && parsed != RedisCommand.UNKNOWN) + { + var resp = _context.CommandMap.GetResp(parsed); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(parsed); + + Ensure(resp.Length); + resp.CopyTo(_buffer.AsSpan(_offset)); + _offset += resp.Length; + _hasCommand = true; + _args++; + _argIndex++; + return; + } + + _hasCommand = true; // unknown command name, framed below like any other token + } + + WriteUtf8Bulk(value, start, length); + _args++; + _argIndex++; + } + + /// Write part of a string as a bulk string, encoding straight into the frame buffer. + /// + /// Pointer-based because Encoding.GetByteCount(ReadOnlySpan<char>) does not exist on + /// netstandard2.0 or net461; the char* overloads do, and this way there is no intermediate + /// array on any target. + /// + private unsafe void WriteUtf8Bulk(string value, int start, int length) + { + int byteCount; + fixed (char* chars = value) + { + byteCount = Encoding.UTF8.GetByteCount(chars + start, length); + var payload = WriteBulk(byteCount, out var payloadOffset); + fixed (byte* bytes = &MemoryMarshal.GetReference(payload)) + { + Encoding.UTF8.GetBytes(chars + start, length, bytes, byteCount); + } + + CommitBulk(payloadOffset, byteCount); + } } internal void AppendFormatted(RedisCommand value) diff --git a/tests/StackExchange.Redis.Build.Tests/SER309.cs b/tests/StackExchange.Redis.Build.Tests/SER309.cs index 8bc10921b..3d9d1a3cf 100644 --- a/tests/StackExchange.Redis.Build.Tests/SER309.cs +++ b/tests/StackExchange.Redis.Build.Tests/SER309.cs @@ -31,7 +31,7 @@ void M(RespContext ctx, RedisKey key, RedisValue value) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx ")); [Fact] public Task TwoSpaces_IsFlagged() => VerifyAsync( @@ -44,7 +44,7 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" ")); [Fact] public Task LeadingCommandName_IsFlagged() => VerifyAsync( @@ -57,7 +57,7 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments("SET ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments("SET ")); [Fact] public Task EveryLiteralIsReportedSeparately() => VerifyAsync( @@ -70,8 +70,8 @@ void M(RespContext ctx, RedisKey key, RedisValue value) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx "), - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(1).WithArguments(" xx")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx "), + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(1).WithArguments(" xx")); [Fact] public Task LeadingSpace_IsFlagged() => VerifyAsync( @@ -84,7 +84,7 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" ")); [Fact] public Task TrailingSpace_IsFlagged() => VerifyAsync( @@ -97,7 +97,7 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" ")); // ---- negatives --------------------------------------------------------------------------------- diff --git a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs index 789042617..f039965fb 100644 --- a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs +++ b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs @@ -71,7 +71,7 @@ void M(RespContext ctx, RedisKey key, RedisValue value) } """, 0, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx")); [Fact] public Task SeparatorsAreKeptOnBothSides() => VerifyFixAsync( @@ -96,7 +96,7 @@ void M(RespContext ctx, RedisKey key, RedisValue value) } """, 0, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx ")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx ")); [Fact] public Task MatchingIsCaseInsensitive() => VerifyFixAsync( @@ -121,7 +121,7 @@ void M(RespContext ctx, RedisKey key) } """, 0, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" NX")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" NX")); // ---- cases with no fix ------------------------------------------------------------------------- @@ -151,7 +151,7 @@ void M(RespContext ctx, RedisKey key) } """, 0, - [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" withsave")], + [Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" withsave")], MissingGeneratedBody("C.Withsave")); [Fact] @@ -180,7 +180,7 @@ void M(RespContext ctx, RedisKey key) } """, 0, - [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" lib-ver")], + [Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" lib-ver")], MissingGeneratedBody("C.LibVer")); [Fact] @@ -211,7 +211,7 @@ void M(RespContext ctx, RedisKey key) } """, 0, - [Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" SETINFO")], + [Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" SETINFO")], MissingGeneratedBody("C.Setinfo")); [Fact] @@ -226,5 +226,5 @@ void M(RespContext ctx, RedisKey key) } } """, - Diagnostic("SER309", DiagnosticSeverity.Error).WithLocation(0).WithArguments(" nx xx")); + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx xx")); } diff --git a/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs new file mode 100644 index 000000000..c8cc4becd --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Literal text as command and arguments: $"SET {key} {value}". Suboptimal but correct - see the +/// design notes, section 2.1. +/// +public class InterpolatedLiteralCommandTests +{ + // these tests exist to exercise the form SER309 warns about, so the warning is suppressed here and + // nowhere wider - the same discipline the generator uses for SER011 +#pragma warning disable SER309 + + private static string Text(in RespFrame frame) => + Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + [Fact] + public void ALeadingLiteralIsTheCommand() + { + var ctx = new RespContext(); + using var literal = ctx.Execute($"SET {(RedisKey)"mykey"} {(RedisValue)"marc"}"); + using var holes = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"marc"}"); + + // the whole point: the readable spelling must produce the identical frame, or it is not an + // alternative spelling, it is a second implementation + Assert.Equal("*3|$3|SET|$5|mykey|$4|marc|", Text(literal)); + Assert.Equal(Text(holes), Text(literal)); + } + + [Fact] + public void TheLeadingCommandStillGoesThroughTheCommandMap() + { + var renamed = CommandMap.Create(new Dictionary { ["SET"] = "STORE" }); + using var frame = new RespContext(renamed).Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + Assert.Equal("*3|$5|STORE|$1|k|$1|v|", Text(frame)); + } + + [Fact] + public void ADisabledLeadingCommandThrows() + { + var disabled = CommandMap.Create(new HashSet { "SET" }, available: false); + var ctx = new RespContext(disabled); + Assert.Throws(() => + { + using var frame = ctx.Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + }); + } + + [Fact] + public void SplittingOnWhitespaceGetsContainerCommandsRight() + { + // CONFIG is the command and IS mapped; GET is an ordinary argument and is NOT - which is exactly + // how CommandMap works, since it maps container verbs only + using var frame = new RespContext().Execute($"CONFIG GET {(RedisValue)"maxmemory"}"); + Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Text(frame)); + Assert.Equal(3, frame.ArgCount); + } + + [Fact] + public void LiteralsAfterTheCommandAreOrdinaryArguments() + { + using var frame = new RespContext().Execute( + $"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"} EX {(RedisValue)300}"); + Assert.Equal("*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|", Text(frame)); + } + + [Fact] + public void WhitespaceOnlyLiteralsStillContributeNothing() + { + var ctx = new RespContext(); + using var spaced = ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}"); + using var tight = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + Assert.Equal(Text(tight), Text(spaced)); + Assert.Equal(2, spaced.ArgCount); + } + + [Fact] + public void RunsOfWhitespaceCollapse() + { + using var frame = new RespContext().Execute($"CONFIG GET {(RedisValue)"maxmemory"}"); + Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Text(frame)); + } + + [Fact] + public void AnUnknownLeadingCommandIsFramedVerbatim() + { + using var frame = new RespContext().Execute($"FT.SEARCH {(RedisValue)"idx"}"); + Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); + } + + [Fact] + public void TheCommandInfoShapeWorks() + { + // the motivating example: a command name as an argument, alongside a literal subcommand + var renamed = CommandMap.Create(new Dictionary { ["HGET"] = "HASHGET" }); + using var frame = new RespContext(renamed).Execute($"COMMAND INFO {"HGET".Command()}"); + + // the argument must be the MAPPED name - the server knows a renamed command only by that + Assert.Equal("*3|$7|COMMAND|$4|INFO|$7|HASHGET|", Text(frame)); + } + + [Fact] + public void NonAsciiLiteralsEncodeCorrectly() + { + using var frame = new RespContext().Execute($"ECHO héllo{(RedisValue)"!"}"); + Assert.Equal("*3|$4|ECHO|$6|héllo|$1|!|", Text(frame)); + } + + [Fact] + public void KeysAreStillOnlyMarkedFromKeyHoles() + { + using var frame = new RespContext().Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + + // a literal token is never a key: it cannot be, since key-ness is what the hole type says + Assert.Equal(1, frame.KeyCount); + var ranges = new KeyRange[1]; + Assert.Equal(1, frame.TryGetKeys(ranges)); + Assert.Equal("k", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + } +#pragma warning restore SER309 +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index 9124295c5..b8f4dde95 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -480,29 +480,31 @@ public void SingleSpacesAreAllowedAndDiscarded() #pragma warning disable SER309 // deliberately exercising the discard path the analyzer exists to prevent [Fact] - public void LiteralsAreDiscardedNotRejectedAtRuntime() + public void LiteralsBecomeArgumentsRatherThanBeingDiscarded() { - // AppendLiteral is a no-op: rejection is the ANALYZER's job, as an error with a fix. A runtime - // check would add nothing, because discarding a literal leaves a well-formed frame with an - // argument missing - the command is wrong, but the connection is not. Literals never contributed - // to *N, so the header stays correct either way. + // literals used to be dropped and the analyzer rejected them outright; now they are tokenized, so + // the readable spelling works and the analyzer only warns that it resolves per call var ctx = new RespContext(); using var twoSpaces = ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}"); using var hyphen = ctx.Execute($"{RedisCommand.GET}-{(RedisKey)"k"}"); + // whitespace-only is still nothing; anything else is now an argument Assert.Equal(new[] { "GET", "k" }, Parse(twoSpaces.Span)); - Assert.Equal(new[] { "GET", "k" }, Parse(hyphen.Span)); Assert.Equal(2, twoSpaces.ArgCount); + + Assert.Equal(new[] { "GET", "-", "k" }, Parse(hyphen.Span)); + Assert.Equal(3, hyphen.ArgCount); } [Fact] - public void ALiteralCommandStillFailsBecauseThereIsNoCommandHole() + public void ALiteralCommandNowSuppliesTheCommand() { - // $"SET {key}" discards "SET ", so nothing ever supplied a command - which IS caught, because the - // handler cannot frame a key before it has one + // this used to throw: "SET " was discarded, so nothing supplied a command and the key could not be + // framed. The leading token is now the command, so it renders exactly like the hole form. var ctx = new RespContext(); - Assert.Throws(() => ctx.Execute($"SET {(RedisKey)"k"}").Dispose()); + using var frame = ctx.Execute($"SET {(RedisKey)"k"}"); + Assert.Equal(new[] { "SET", "k" }, Parse(frame.Span)); } #pragma warning restore SER309 } From 9bb6f0293839cf488e735e83ac0fe3e3ddeec297 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 10:37:10 +0100 Subject: [PATCH 065/360] Fixer: promote a leading literal to a command, forking on accessibility 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. --- design/interpolated-resp-writer.md | 18 +++ .../RespLiteralCodeFixProvider.cs | 105 ++++++++++++++++++ .../SER309CodeFix.cs | 83 ++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index a20fd408d..22ecf45f4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -93,6 +93,24 @@ So `$"SET {key} {value}"` renders byte-identically to `$"{RedisCommand.SET}{key} compile it bought correctness we did not actually need. Working-but-slower beats not-working, and the warning still points at the faster spelling. +**The fixer forks on accessibility, not on preference.** A *leading* literal is the command, so it gets a +different fix from a token in any other position - the same positional rule the writer applies at runtime, +so the fix and the behaviour cannot disagree: + +| | offered | +| --- | --- | +| leading, `RedisCommand` reachable | `RedisCommand.SET` - no parse, and a typo is a compile error | +| leading, not reachable | a `static readonly RespCommand` field, `"SET".Command(preform: true)` | +| anywhere else | the existing `[Resp]` fragment fix | + +`RedisCommand` is internal, so this library's own code takes the first row and everyone else takes the +second. The field uses `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, where the bytes are +then built once rather than per call. + +That external half is what the code-fix tests actually exercise, since the harness compiles against the +public surface with no `InternalsVisibleTo` - so even `SET` gets the field there, which is exactly right. + **Splitting on whitespace gets container commands right for free.** `$"CONFIG GET {name}"` yields three arguments, with `CONFIG` mapped and `GET` not - which is precisely how `CommandMap` behaves, since it maps container verbs only. That was not designed for; it fell out. diff --git a/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs index 071677aaa..22249ed90 100644 --- a/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs +++ b/eng/StackExchange.Redis.CodeFixes/RespLiteralCodeFixProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Composition; using System.Threading; using System.Threading.Tasks; @@ -37,6 +38,8 @@ public sealed class RespLiteralCodeFixProvider : CodeFixProvider private const string TokenProperty = "Token"; private const string RespAttributeName = "StackExchange.Redis.Interpolated.RespAttribute"; private const string FragmentTypeName = "StackExchange.Redis.Interpolated.RespFragment"; + private const string CommandTypeName = "StackExchange.Redis.Interpolated.RespCommand"; + private const string RedisCommandTypeName = "StackExchange.Redis.RedisCommand"; /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(LiteralNotSentId); @@ -65,6 +68,14 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true) is not InterpolatedStringTextSyntax text) continue; + // A LEADING literal is the command, not an argument, so it wants a different fix: the enum when + // that is reachable - fastest and compile-checked - and otherwise a resolved-once field. + if (IsLeading(text)) + { + RegisterCommandFixes(context, diagnostic, model, root, text, token!); + continue; + } + var match = FindFragment(model.Compilation, token!, context.CancellationToken); if (match is not null) { @@ -100,6 +111,100 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) } } + /// Whether this literal is the first content of its interpolated string. + /// + /// That is what makes it the command rather than an argument - the same positional rule the writer + /// applies at runtime, so the fix and the behaviour cannot disagree. + /// + private static bool IsLeading(InterpolatedStringTextSyntax text) + => text.Parent is InterpolatedStringExpressionSyntax parent + && parent.Contents.Count > 0 + && parent.Contents[0] == text; + + /// + /// Offer the right way to say a leading command: the RedisCommand enum where it is accessible, + /// otherwise a resolved-once RespCommand field. + /// + /// + /// The fork is accessibility, not preference. RedisCommand is internal, so this library's own + /// code gets the enum - no parse, and a typo is a compile error - while everyone else gets a field. + /// The field is declared with preform: true because it is a static: for a command this library + /// knows that is a no-op (the command map already holds the bytes), and for anything else - a module + /// command - it means the bytes are built once instead of on every call. + /// + private static void RegisterCommandFixes( + CodeFixContext context, + Diagnostic diagnostic, + SemanticModel model, + SyntaxNode root, + InterpolatedStringTextSyntax text, + string token) + { + var enumType = model.Compilation.GetTypeByMetadataName(RedisCommandTypeName); + if (enumType is not null && model.IsAccessible(text.SpanStart, enumType)) + { + var member = enumType.GetMembers() + .FirstOrDefault(m => m.Kind == SymbolKind.Field + && string.Equals(m.Name, token, StringComparison.OrdinalIgnoreCase)); + if (member is not null) + { + var name = enumType.ToMinimalDisplayString(model, text.SpanStart) + "." + member.Name; + context.RegisterCodeFix( + CodeAction.Create( + title: "Use '" + name + "'", + createChangedDocument: _ => Task.FromResult(Apply(context.Document, root, text, name)), + equivalenceKey: LiteralNotSentId + ":command-enum"), + diagnostic); + return; + } + } + + var host = text.FirstAncestorOrSelf(); + if (host is null) return; + + var commandType = model.Compilation.GetTypeByMetadataName(CommandTypeName); + if (commandType is null) return; + + var field = MemberNameFor(token) + "Command"; + var typeName = commandType.ToMinimalDisplayString(model, host.SpanStart); + context.RegisterCodeFix( + CodeAction.Create( + title: "Declare '" + field + "' here and use it", + createChangedDocument: _ => Task.FromResult( + DeclareCommand(context.Document, root, text, host, field, token, typeName)), + equivalenceKey: LiteralNotSentId + ":command-field"), + diagnostic); + } + + /// Declare a resolved-once command field and use it in place of the literal. + private static Document DeclareCommand( + Document document, + SyntaxNode root, + InterpolatedStringTextSyntax text, + TypeDeclarationSyntax host, + string field, + string token, + string commandTypeName) + { + var tracked = root.TrackNodes(text, host); + + var currentText = tracked.GetCurrentNode(text)!; + var afterLiteral = tracked.ReplaceNode(currentText, Replacements(currentText, field)); + + var currentHost = afterLiteral.GetCurrentNode(host)!; + + // preform: true because this is a static - a no-op for a known command, since the command map + // already holds its bytes, and a real saving for a module command + var declaration = SyntaxFactory.ParseMemberDeclaration( + "private static readonly " + commandTypeName + " " + field + + " = \"" + token + "\".Command(preform: true);")! + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed) + .WithAdditionalAnnotations(Formatter.Annotation); + + return document.WithSyntaxRoot( + afterLiteral.ReplaceNode(currentHost, currentHost.AddMembers(declaration))); + } + /// /// Replace the literal with (space) (hole) (space), keeping the separators it had - a single space either /// side, since more than one is itself the diagnostic. diff --git a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs index f039965fb..03c425218 100644 --- a/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs +++ b/tests/StackExchange.Redis.Build.Tests/SER309CodeFix.cs @@ -227,4 +227,87 @@ void M(RespContext ctx, RedisKey key) } """, Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx xx")); + + // NOTE this harness compiles against the PUBLIC surface, with no InternalsVisibleTo - so it is an + // external caller, and RedisCommand is genuinely out of reach. That is the half of the fork worth + // testing here: even for a command the library knows, an outside caller gets the field, because the + // enum it would otherwise use is internal. + [Fact] + public Task LeadingCommand_KnownName_ExternallyDeclaresAField() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute($"{|#0:SET |}{key}{value}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute($"{SetCommand} {key}{value}"); + } + + private static readonly RespCommand SetCommand = "SET".Command(preform: true); + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments("SET ")); + + [Fact] + public Task LeadingCommand_UnknownName_DeclaresAPreformedField() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisValue value) + { + using var frame = ctx.Execute($"{|#0:FT.SEARCH |}{value}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisValue value) + { + using var frame = ctx.Execute($"{FtSearchCommand} {value}"); + } + + private static readonly RespCommand FtSearchCommand = "FT.SEARCH".Command(preform: true); + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments("FT.SEARCH ")); + + [Fact] + public Task NonLeadingToken_StillOffersTheFragmentFix() => VerifyFixAsync( + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{value}{|#0: nx|}"); + } + } + """, + Declarations + """ + + class C + { + void M(RespContext ctx, RedisKey key, RedisValue value) + { + using var frame = ctx.Execute("SET", $"{key}{value} {RespLiterals.Nx}"); + } + } + """, + 0, + Diagnostic("SER309", DiagnosticSeverity.Warning).WithLocation(0).WithArguments(" nx")); } From ef5cbf453a57039e67396db3ce229de6f61069c0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 10:45:41 +0100 Subject: [PATCH 066/360] Command resolution lives on the context, in one place '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) 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. --- .../Interpolated/RespCommand.cs | 10 ++--- .../Interpolated/RespCommandHandler.cs | 22 +++-------- .../Interpolated/RespContext.cs | 39 +++++++++++++++++++ 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespCommand.cs b/src/StackExchange.Redis/Interpolated/RespCommand.cs index dfc7beb57..18bfbcd0b 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommand.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommand.cs @@ -75,16 +75,14 @@ internal RespCommand(string name) /// Whether the RESP bytes were built once, rather than encoded on each use. public bool IsPreformed => _resp is not null; - /// The pre-framed RESP for this command, honouring when it applies. - /// The command map of the context being written. - internal ReadOnlySpan GetResp(CommandMap map) + /// The RESP for this command, honouring the context's command map when it applies. + /// The context being written. + internal ReadOnlySpan GetResp(in RespContext context) { if (_resp is not null) return _resp; // unknown, preformed: the map has no opinion on it if (_name is not null) return default; // unknown, per-call: the caller encodes it - var resp = map.GetResp(_command); - if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(_command); - return resp; + return context.ResolveCommand(_command); } /// diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 95662c0c6..729e1d1cb 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -79,8 +79,7 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con internal RespCommandHandler(int literalLength, int formattedCount, RespContext context, RedisCommand command) { // resolve FIRST: this throws before anything is rented - var resp = context.CommandMap.GetResp(command); - if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + var resp = context.ResolveCommand(command); _context = context; _buffer = ArrayPool.Shared.Rent(HeaderMax + 64 + resp.Length + literalLength + (formattedCount * 24)); @@ -104,13 +103,7 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con { if (command is null) throw new ArgumentNullException(nameof(command)); - ReadOnlySpan resp = default; - var known = RedisCommandMetadata.TryParseCI(command.AsSpan(), out var parsed) && parsed != RedisCommand.UNKNOWN; - if (known) - { - resp = context.CommandMap.GetResp(parsed); - if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(parsed); - } + var known = context.TryResolveCommand(command.AsSpan(), out var resp); var nameBytes = known ? 0 : Encoding.UTF8.GetByteCount(command); _context = context; @@ -206,12 +199,8 @@ private void AppendToken(string value, int start, int length) { // first thing written: this is the command. A name we know goes through the map - which may // rename or disable it; anything else is framed verbatim, as Execute(string, ...) already does - if (RedisCommandMetadata.TryParseCI(value.AsSpan(start, length), out var parsed) - && parsed != RedisCommand.UNKNOWN) + if (_context.TryResolveCommand(value.AsSpan(start, length), out var resp)) { - var resp = _context.CommandMap.GetResp(parsed); - if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(parsed); - Ensure(resp.Length); resp.CopyTo(_buffer.AsSpan(_offset)); _offset += resp.Length; @@ -255,8 +244,7 @@ internal void AppendFormatted(RedisCommand value) { if (_hasCommand) throw new InvalidOperationException("The command must be the first argument, and may only be given once."); - var resp = _context.CommandMap.GetResp(value); - if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(value); + var resp = _context.ResolveCommand(value); Ensure(resp.Length); resp.CopyTo(_buffer.AsSpan(_offset)); @@ -288,7 +276,7 @@ public void AppendFormatted(RespCommand value) // resolution happens HERE, not at construction: a known command still has to go through this // context's map, which may rename or disable it - var resp = value.GetResp(_context.CommandMap); + var resp = value.GetResp(in _context); if (resp.IsEmpty) { // an unknown command kept as a name: encode straight into the frame, no intermediate array diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 558a8e6ee..caf4213a9 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -87,6 +87,45 @@ public bool TryGetService([NotNullWhen(true)] out T? service) } } + /// + /// Resolve a known command to the bytes this context would send, honouring the command map. + /// + /// The command to resolve. + /// If the command map disables it. + internal ReadOnlySpan ResolveCommand(RedisCommand command) + { + var resp = CommandMap.GetResp(command); + if (resp.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + return resp; + } + + /// + /// Resolve a command name to the bytes this context would send. + /// + /// The command name. + /// The bytes to send, when the name is one this library knows. + /// + /// false when the name is not a known command, in which case the caller frames it verbatim - + /// no command map can affect it, because the map is built by walking the RedisCommand enum. + /// + /// If the name is known but the command map disables it. + /// + /// One place, because there are four callers - the string constructor, a leading literal token, + /// , and the enum overload - and "parse, map, and throw if disabled" is + /// exactly the sort of three-step rule that drifts when it is written out four times. + /// + internal bool TryResolveCommand(ReadOnlySpan name, out ReadOnlySpan resp) + { + if (RedisCommandMetadata.TryParseCI(name, out var parsed) && parsed != RedisCommand.UNKNOWN) + { + resp = ResolveCommand(parsed); + return true; + } + + resp = default; + return false; + } + /// The client-side cache attached to this context, or null for none. /// Convenience over ; the cache is not a field. public RespClientCache? Cache => TryGetService(out var cache) ? cache : null; From 002961b9e6cafc8adfe7943d106eef9decbab1c2 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:01:21 +0100 Subject: [PATCH 067/360] Record why the Compose using/ref limitation is accepted 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. --- design/interpolated-resp-writer.md | 5 +++++ src/StackExchange.Redis/Interpolated/RespContext.cs | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 22ecf45f4..e42674a09 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1081,6 +1081,11 @@ does exactly the same — it rents from `ArrayPool.Shared` and abandons th interpolation throws, because the compiler emits no `try`/`finally` around the append sequence. Broken usage dumping an incomplete buffer is the established behaviour of the pattern. +The same precedent settles a second wart in the `Compose` path: the handler cannot be held by `using`, +because a `using` variable cannot be passed by `ref` (CS1657), so a throwing window between `Compose` and +`Execute` needs try/finally. `DefaultInterpolatedStringHandler` has exactly this shape and exactly this +limitation; it is a property of the pattern rather than of this design. + It is also harmless here: `MemoryTrackedPool` is a thin wrapper over `ArrayPool.Shared` (`MemoryTrackedPool.cs:34`) with no outstanding-rental tracking and no budget, so a dropped buffer is simply garbage. diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index caf4213a9..625f79385 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -236,7 +236,10 @@ public RespContext WithServices(object? services) /// /// NOTE: the handler cannot be held by using, because a using variable cannot be passed /// by ref (CS1657). If the window between Compose and Execute can throw, use try/finally and - /// call . + /// call . This is the same constraint + /// DefaultInterpolatedStringHandler lives under, and accepted for the same reason - see + /// design doc section 6.5, where the identical precedent covers abandoning the rented buffer when + /// an interpolation throws. /// /// public RespCommandHandler Compose([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) From d2d6fc856a4bfe633df931391142b042a6b9ff99 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:22:37 +0100 Subject: [PATCH 068/360] Plug in IRespTarget: IRedis carries .Context 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. --- design/interpolated-resp-writer.md | 27 +++++++++++ .../Availability/MultiGroupDatabase.cs | 4 ++ .../Availability/MultiGroupSubscriber.cs | 4 ++ src/StackExchange.Redis/Interfaces/IRedis.cs | 9 +++- .../Interpolated/RespContext.cs | 4 +- .../Interpolated/RespDatabase.cs | 46 +++++++++++++++++++ .../Interpolated/RespExecutor.cs | 8 +++- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 8 ++++ .../PublicAPI/PublicAPI.Unshipped.txt | 11 ++--- src/StackExchange.Redis/RedisBase.cs | 10 ++++ .../RespSurfaceTests.cs | 21 +++++---- 11 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespDatabase.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e42674a09..6e5b6d578 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2079,6 +2079,33 @@ looks like evidence, which is worse than not having it. The flag decision now go `cache.PermitsCaching(flags)`, so the cache observes every refusal without probing anything it has been told to leave alone. +#### Plugged in + +`IRedis` now inherits `IRespTarget`, so `IDatabase`, `IServer` and `ISubscriber` all carry `.Context` from +**one** interface edit. The blast radius inside the library was four types, which is smaller than it +sounds: + +| | | +| --- | --- | +| `RedisBase` | throws - covers `RedisDatabase`, `RedisServer`, `RedisSubscriber` | +| `MultiGroupDatabase`, `MultiGroupSubscriber` | throw | +| `KeyPrefixedDatabase` | **implemented**: `Inner.Context.WithKeyPrefix(Prefix)` | +| `RespDatabase` (new) | the minimal one that actually works | + +`KeyPrefixedDatabase` is worth calling out: 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.** Dispatch is an implementation concern; the public surface is the +context plus extension members. That keeps the executor chain - retry, and whatever follows - reshapeable +without it being a breaking change, and it is why `RespContext.Executor` and `WithExecutor` are internal +too. + +**`RespDatabase` has no command methods**, which is the point rather than an omission: `Set`, `Get` and +everything after are extension members over the context, so the type does not grow as the surface does. + +Connection-backed types throw for now. Wiring a rendered frame through the existing message pipeline is +separate work, and nothing here needs to wait for it. + #### Four things to settle before building it 1. ~~**`ref readonly` and `async` do not mix.**~~ **Settled: by value.** A `ref readonly` local cannot cross an `await`, and the diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index 72ebc8901..9505dd357 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -14,6 +14,10 @@ namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, int database, object? asyncState) : IDatabase, IInternalDatabaseAsync { + /// + public Interpolated.RespContext Context + => throw new NotImplementedException("The context surface is not yet wired for multi-group."); + public object? AsyncState => asyncState; public int Database => database < 0 ? GetActiveDatabase().Database : database; diff --git a/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs index 9f83b145d..1e402598a 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs @@ -6,6 +6,10 @@ namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupSubscriber(MultiGroupMultiplexer parent, object? asyncState) : ISubscriber { + /// + public Interpolated.RespContext Context + => throw new NotImplementedException("The context surface is not yet wired for multi-group."); + // for a lot of things, we can defer through to the active implementation private ISubscriber GetActiveSubscriber() => parent.Active.GetSubscriber(asyncState); diff --git a/src/StackExchange.Redis/Interfaces/IRedis.cs b/src/StackExchange.Redis/Interfaces/IRedis.cs index 3507aa433..e1a86f1c1 100644 --- a/src/StackExchange.Redis/Interfaces/IRedis.cs +++ b/src/StackExchange.Redis/Interfaces/IRedis.cs @@ -1,11 +1,18 @@ using System; +using StackExchange.Redis.Interpolated; namespace StackExchange.Redis { /// /// Common operations available to all redis connections. /// - public partial interface IRedis : IRedisAsync + /// + /// is inherited here rather than on each of IDatabase, IServer + /// and ISubscriber: one member, in one place. That member is intended to be the LAST addition to + /// these interfaces - once a context is reachable, new surface hangs off it as extension members and + /// breaks nobody. See design notes section 9.4. + /// + public partial interface IRedis : IRedisAsync, IRespTarget { /// /// This command is often used to test if a connection is still alive, or to measure latency. diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 625f79385..bf3922a5e 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -54,7 +54,7 @@ internal RespContext( /// Behaviour composes here - a retrying or caching executor is a decorator around an inner one - while /// configuration composes on the context, via . They are not alternatives. /// - public IRespExecutor? Executor { get; } + internal IRespExecutor? Executor { get; } private readonly object? _services; @@ -197,7 +197,7 @@ public RespContext WithChannelPrefix(RedisChannel channelPrefix) /// A copy of this context that sends through . /// The executor to send through. - public RespContext WithExecutor(IRespExecutor? executor) + internal RespContext WithExecutor(IRespExecutor? executor) => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, executor, _services); /// A copy of this context carrying . diff --git a/src/StackExchange.Redis/Interpolated/RespDatabase.cs b/src/StackExchange.Redis/Interpolated/RespDatabase.cs new file mode 100644 index 000000000..d25eb0e05 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespDatabase.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. A minimal : a context, and nothing else. + /// + /// + /// + /// This is what the context surface looks like without a live connection behind it - enough to exercise + /// target.Strings.Set(...) end to end, and to show that the whole public surface really is one + /// member plus extension members. The connection-backed types (RedisDatabase and friends) throw + /// from for now: routing a rendered frame through the existing + /// message pipeline is separate work, and there is no reason to hold this up behind it. + /// + /// + /// Note what is not here: no command methods. Set, Get and everything after them + /// are extension members over the context, so this type does not grow as the surface does - which is + /// the entire argument of design notes section 9.4, made concrete. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class RespDatabase : IRespTarget + { + /// Create a database over a context. + /// The context commands are composed and sent through. + public RespDatabase(in RespContext context) => Context = context; + + /// + public RespContext Context { get; } + + /// A database for the same connection, with every key prefixed. + /// The prefix to apply. + /// + /// One context clone, with no per-method forwarding - the whole write half of + /// KeyPrefixedDatabase. + /// + public RespDatabase WithKeyPrefix(RedisKey prefix) => new(Context.WithKeyPrefix(prefix)); + + /// A database bound to a different database index. + /// The database index. + public RespDatabase WithDatabase(int database) => new(Context.WithDatabase(database)); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 6adc14f65..39043fb1a 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -10,6 +10,11 @@ namespace StackExchange.Redis.Interpolated /// EXPERIMENTAL SPIKE. Something that can issue a rendered request. /// /// + /// Internal. Dispatch is an implementation concern; the public surface is the context and the + /// extension members over it. Keeping this internal means the executor chain - retry, and whatever + /// follows - can be reshaped without it being a breaking change. + /// + /// /// /// Neither side is a span, and neither side is a byte[]. A span cannot cross an /// await, and cannot be parked in a backlog for a resend after a reconnect - so a span request @@ -24,8 +29,7 @@ namespace StackExchange.Redis.Interpolated /// reference held by the caller, who releases it. Whoever retains, releases. /// /// - [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public interface IRespExecutor + internal interface IRespExecutor { /// The database requests run against; part of a cached entry's identity. int Database { get; } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 3353c0d6c..5a82e2a18 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -8,6 +8,14 @@ namespace StackExchange.Redis.KeyspaceIsolation { internal sealed partial class KeyPrefixedDatabase : KeyPrefixed, IDatabase { + /// + /// + /// The worked example from design notes section 8.4: the entire write half of key-prefixing is one + /// context clone. Everything this class does by forwarding ~2600 lines of overrides, the + /// context-based surface gets from this single line. + /// + public Interpolated.RespContext Context => Inner.Context.WithKeyPrefix(Prefix); + public KeyPrefixedDatabase(IDatabase inner, byte[] prefix) : base(inner, prefix) { } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 5c9d86717..57411e17f 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,9 +1,5 @@ #nullable enable StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.CommandFlags -[SER010]StackExchange.Redis.Interpolated.IRespExecutor -[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Database.get -> int -[SER010]StackExchange.Redis.Interpolated.IRespExecutor.Send(in StackExchange.Redis.Interpolated.RespRequest request) -> StackExchange.Redis.Interpolated.RespPayload! -[SER010]StackExchange.Redis.Interpolated.IRespExecutor.SendAsync(StackExchange.Redis.Interpolated.RespRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.IRespTarget @@ -67,7 +63,6 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespContext.Database.get -> int [SER010]StackExchange.Redis.Interpolated.RespContext.Execute(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespContext.Execute(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame -[SER010]StackExchange.Redis.Interpolated.RespContext.Executor.get -> StackExchange.Redis.Interpolated.IRespExecutor? [SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void [SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType @@ -76,10 +71,14 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespContext.WithCancellationToken(System.Threading.CancellationToken cancellationToken) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithChannelPrefix(StackExchange.Redis.RedisChannel channelPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext -[SER010]StackExchange.Redis.Interpolated.RespContext.WithExecutor(StackExchange.Redis.Interpolated.IRespExecutor? executor) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServices(object? services) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespDatabase +[SER010]StackExchange.Redis.Interpolated.RespDatabase.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespDatabase.RespDatabase(in StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespDatabase.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespDatabase! +[SER010]StackExchange.Redis.Interpolated.RespDatabase.WithKeyPrefix(StackExchange.Redis.RedisKey prefix) -> StackExchange.Redis.Interpolated.RespDatabase! [SER010]StackExchange.Redis.Interpolated.RespExecutor [SER010]StackExchange.Redis.Interpolated.RespFragment [SER010]StackExchange.Redis.Interpolated.RespFragment.ArgCount.get -> int diff --git a/src/StackExchange.Redis/RedisBase.cs b/src/StackExchange.Redis/RedisBase.cs index 84981b71f..92e13a37b 100644 --- a/src/StackExchange.Redis/RedisBase.cs +++ b/src/StackExchange.Redis/RedisBase.cs @@ -6,6 +6,16 @@ namespace StackExchange.Redis { internal abstract partial class RedisBase : IRedis { + /// + /// + /// Not yet implemented for connection-backed types. The context surface is being brought up + /// against a minimal implementation first (RespDatabase); wiring it to a live multiplexer + /// means routing a rendered frame through the existing message pipeline, which is separate work. + /// + public Interpolated.RespContext Context + => throw new NotImplementedException( + "The context surface is not yet wired to a live connection; see RespDatabase."); + internal static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); internal readonly ConnectionMultiplexer multiplexer; protected readonly object? asyncState; diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index 1d31dee5f..7ef115f96 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -33,13 +33,7 @@ public ValueTask SendAsync(RespRequest request, CancellationToken c => new(Send(request)); } - /// A minimal root object, standing in for what IDatabase would become. - private sealed class FakeTarget(RespContext context) : IRespTarget - { - public RespContext Context { get; } = context; - } - - private static FakeTarget Target(FakeExecutor executor, RespClientCache? cache = null) + private static RespDatabase Target(FakeExecutor executor, RespClientCache? cache = null) => new(new RespContext().WithExecutor(executor).WithCache(cache)); [Fact] @@ -79,7 +73,7 @@ public async Task WithKeyPrefixIsJustAContextClone() var target = Target(executor); // this is the whole of KeyPrefixedDatabase's write half - no per-method forwarding - var tenant = new FakeTarget(target.Context.WithKeyPrefix("t7:")); + var tenant = target.WithKeyPrefix("t7:"); await tenant.Strings.Set("user:1", "marc"); Assert.Equal("*3|$3|SET|$9|t7:user:1|$4|marc|", Assert.Single(executor.Sent)); @@ -130,10 +124,19 @@ public async Task NoClientCacheOptsASingleCallOut() Assert.Equal(2, executor.Sent.Count); // the opted-out call did not read the cached entry } + [Fact] + public void ConnectionBackedTypesThrowForNow() + { + // IRedis carries the member, so IDatabase/IServer/ISubscriber all have it - but wiring it to a live + // multiplexer is separate work, so those throw while RespDatabase is what actually runs + IRespTarget target = (IRespTarget)(object)new RespDatabase(new RespContext()); + Assert.Equal(0, target.Context.Database); // the minimal one works + } + [Fact] public void MissingExecutorFailsLoudlyRatherThanSilently() { - var target = new FakeTarget(new RespContext()); + var target = new RespDatabase(new RespContext()); Assert.Throws(() => target.Strings.Get("mykey")); } } From 20dec9b13dfb99ff1247d4d0ca8e905ea4e0b0aa Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:29:20 +0100 Subject: [PATCH 069/360] End to end: a rendered frame through the real pipeline, to a real server 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. --- design/interpolated-resp-writer.md | 17 +++ .../Interpolated/RespMessageExecutor.cs | 107 +++++++++++++++ .../RespEndToEndTests.cs | 127 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs create mode 100644 tests/StackExchange.Redis.Tests/RespEndToEndTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 6e5b6d578..ee40fac83 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1297,6 +1297,23 @@ Notes from building it: - Both routes render **byte-identically**, pinned by a test. That is a correctness property, not tidiness: the frame is the cache key, so two routes that disagreed would cache the same logical command twice. +#### The other direction: a frame becomes a message + +`RespMessageExecutor` sends a pre-rendered frame through the existing 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 (overriding `SetResult` rather than `SetResultCore`, so +it runs before `MovePastBof()` consumes the prefix bytes the capture needs). + +**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 - and it is scaffolding rather than a destination, since the `Message` machinery is expected +to go away entirely in favour of execution life-cycle state. + +This is what made `RespEndToEndTests` possible: `target.Strings.Set/Get` against a real server, with the +legacy API cross-checking that the bytes landed. Before it, everything was validated against fakes - which +proves the shape but never that a server accepts the bytes, since only framing was ever in question. + Still open for a real transition: a cacheability predicate (Redis excludes `FT.*`, probabilistic and time-series types, and non-deterministic commands such as `HRANDFIELD`/`ZRANDMEMBER`/`HSCAN`), and running a `ResultProcessor` against a cached payload — it takes `ref RespReader`, which `RespPayload.GetReader()` diff --git a/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs new file mode 100644 index 000000000..166d94b0e --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs @@ -0,0 +1,107 @@ +using System; +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Messages; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Sends a pre-rendered frame through the existing message pipeline. + /// + /// + /// + /// The transition bridge, in the direction that matters for actually talking to a server: the frame is + /// already framed, so the Message wrapping it only has to blit bytes, and the + /// ResultProcessor only has to hand the raw reply back. Everything between - connection + /// selection, the backlog, multiplexing, failover - is the existing pipeline, untouched. + /// + /// + /// This exists so the new surface can be validated end to end against a real server before anything is + /// rewritten. It is scaffolding, not a destination: long term the Message machinery goes + /// away entirely, replaced by state representing an execution life-cycle, and a rendered frame reaches + /// the connection with nothing in between. + /// + /// + /// Worth noting what the wrapping costs, because it is almost nothing: one message type covers + /// every pre-formatted command. The library currently has 75 WriteImpl overrides across 20 + /// files, and they exist only because each command shape writes itself differently. Once the bytes + /// arrive already framed, there is one shape. + /// + /// + internal sealed class RespMessageExecutor : IRespExecutor + { + private readonly RedisBase _target; + + internal RespMessageExecutor(RedisBase target, int database) + { + _target = target; + Database = database; + } + + public int Database { get; } + + public RespPayload Send(in RespRequest request) + { + var message = new FrameMessage(Database, request); + return _target.ExecuteSync(message, PayloadProcessor.Instance) + ?? throw new RedisException("No reply."); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + { + // the existing pipeline has no cancellation; the token is observed by the caller's await, which + // is the model settled in design notes section 6.11 - the request completes by itself + var message = new FrameMessage(Database, request); + return new(_target.ExecuteAsync(message, PayloadProcessor.Instance, defaultValue: null!)!); + } + + /// A message whose body is already framed: writing it is a blit. + private sealed class FrameMessage : Message + { + private readonly RespRequest _request; + + internal FrameMessage(int database, in RespRequest request) + : base(database, request.Flags & ~Message.MaskRetryCategory | request.Flags, RedisCommand.UNKNOWN) + { + _request = request; + } + + // an over-estimate is allowed, and the frame knows exactly + public override int ArgCount => _request.ArgCount; + + // the slot was folded during the write, so routing needs no second look at the keys + public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => _request.Slot; + + protected override void WriteImpl(in MessageWriter writer) => writer.WriteRaw(_request.Span); + } + + /// Captures the raw reply, undecoded, for the handler (or the cache) to read. + /// + /// Same shape as ResultProcessor.RespResult, and for the same reason: SetResult is + /// overridden rather than SetResultCore, so this runs before the base implementation's + /// MovePastBof() consumes the prefix and length bytes that the capture needs. + /// + private sealed class PayloadProcessor : ResultProcessor + { + internal static readonly PayloadProcessor Instance = new(); + + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) + { + var totalBytes = checked((int)reader.ProtocolBytesRemaining); + + var probe = reader; + probe.MovePastBof(); + if (probe.IsError) return base.SetResult(connection, message, ref reader); + + var buffer = ArrayPool.Shared.Rent(Math.Max(1, totalBytes)); + reader.CopyRawTo(buffer.AsSpan(0, totalBytes)); + SetResult(message, new RespPayload(RESPite.Buffers.RefCountedBuffer.Adopt(buffer, buffer.Length), 0, totalBytes)); + return true; + } + + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) => + throw new NotSupportedException(); // SetResult is fully overridden above + } + } +} diff --git a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs new file mode 100644 index 000000000..9ffaf8fe5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The context surface against a REAL server, via the existing message pipeline. +/// +/// +/// Everything before this was validated against fakes, which proves the shape but not that the bytes are +/// acceptable - only framing was ever in question, never semantics. These are the first commands from the +/// new writer that a server has actually seen. +/// +public class RespEndToEndTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + private static RespDatabase NewSurface(IConnectionMultiplexer conn, int db, RespClientCache? cache = null) + { + var database = (RedisBase)conn.GetDatabase(db); + var context = new RespContext(database.multiplexer.CommandMap, database: db) + .WithExecutor(new RespMessageExecutor(database, db)) + .WithCache(cache); + return new RespDatabase(context); + } + + [Fact] + public async Task SetAndGetAgainstARealServer() + { + await using var conn = Create(); + var key = Me(); + var legacy = conn.GetDatabase(); + await legacy.KeyDeleteAsync(key); + + var surface = NewSurface(conn, legacy.Database); + + Assert.True(await surface.Strings.Set(key, "marc")); + Assert.Equal("marc", await surface.Strings.Get(key)); + + // cross-check with the existing API: the bytes the new writer produced really did land + Assert.Equal("marc", await legacy.StringGetAsync(key)); + } + + [Fact] + public async Task AMissingKeyComesBackNull() + { + await using var conn = Create(); + var key = Me(); + await conn.GetDatabase().KeyDeleteAsync(key); + + var surface = NewSurface(conn, conn.GetDatabase().Database); + Assert.True((await surface.Strings.Get(key)).IsNull); + } + + [Fact] + public async Task ValuesWrittenByTheLegacyApiAreReadableByTheNewOne() + { + await using var conn = Create(); + var key = Me(); + var legacy = conn.GetDatabase(); + await legacy.StringSetAsync(key, "from-legacy"); + + var surface = NewSurface(conn, legacy.Database); + Assert.Equal("from-legacy", await surface.Strings.Get(key)); + } + + [Fact] + public async Task BinaryAndNonAsciiValuesRoundTrip() + { + await using var conn = Create(); + var key = Me(); + var legacy = conn.GetDatabase(); + var surface = NewSurface(conn, legacy.Database); + + // the framing is length-prefixed, so this is really asking whether the length was computed in + // BYTES rather than characters - the classic way to desynchronise a connection + Assert.True(await surface.Strings.Set(key, "héllo wörld 中文")); + Assert.Equal("héllo wörld 中文", await surface.Strings.Get(key)); + + var blob = new byte[512]; + for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251); + Assert.True(await surface.Strings.Set(key, blob)); + Assert.Equal(blob, (byte[])(await surface.Strings.Get(key))!); + } + + [Fact] + public async Task KeyPrefixIsAppliedOnTheWire() + { + await using var conn = Create(); + var key = Me(); + var legacy = conn.GetDatabase(); + await legacy.KeyDeleteAsync("t7:" + key); + + var tenant = NewSurface(conn, legacy.Database).WithKeyPrefix("t7:"); + Assert.True(await tenant.Strings.Set(key, "marc")); + + // written under the prefix, and NOT under the bare key + Assert.Equal("marc", await legacy.StringGetAsync("t7:" + key)); + Assert.True((await legacy.StringGetAsync(key)).IsNull); + } + + [Fact] + public async Task TheCacheServesTheSecondReadWithoutTouchingTheServer() + { + await using var conn = Create(); + var key = Me(); + var legacy = conn.GetDatabase(); + await legacy.StringSetAsync(key, "first"); + + using var cache = new RespClientCache(); + var surface = NewSurface(conn, legacy.Database, cache); + + Assert.Equal("first", await surface.Strings.Get(key)); + Assert.Equal(1, cache.Stored); + + // change it behind the cache's back - with no CLIENT TRACKING there is no invalidation, so the + // cache still answers "first". That is the correct behaviour for a cache nobody is invalidating, + // and it is exactly why tracking is the next piece of work. + await legacy.StringSetAsync(key, "second"); + Assert.Equal("first", await surface.Strings.Get(key)); + + // and once told, it stops + Assert.True(cache.OnInvalidate(Encoding.UTF8.GetBytes(key))); + Assert.Equal("second", await surface.Strings.Get(key)); + } +} From 18b5077c8a2e479e1b5cfdfe003006cecf37f397 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:46:47 +0100 Subject: [PATCH 070/360] Record the cacheability exclusions review found, and the script question 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. --- design/interpolated-resp-writer.md | 66 ++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index ee40fac83..24e7d341f 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1343,11 +1343,38 @@ it is the one that fails open if written carelessly. already carries flags; whether a command may be cached is a property of the command, and the caller has to say. -**Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. Read-only -commands that must still not be cached: non-deterministic ones (`SRANDMEMBER`, `HRANDFIELD`, -`ZRANDMEMBER`) and cursor-based ones (`SCAN`, `HSCAN`). Those are *our* commands, so they belong in -command metadata rather than in flags — a compile-time property of our own enum should not be pushed onto -every call site. +**Read-only is necessary, not sufficient**, and this is a gate rather than the whole test. The exclusions +are *our* commands, so they belong in command metadata rather than in flags — a compile-time property of +our own enum should not be pushed onto every call site. + +**The metadata table already exists.** `CommandFlags.Category.cs` has a per-command `switch` supplying the +default retry category; a cacheability answer wants to sit beside it, not in a new structure. Same kind of +fact about the same enum. + +**The list is longer than first recorded** (found in review; every entry verified to return +`CommandRetryReadOnly` from that table, so all of them pass the gate today): + +| | why caching is wrong | +| --- | --- | +| `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER` | non-deterministic: a cached "random" answer stops being random | +| `SCAN`, `HSCAN`, `SSCAN`, `ZSCAN` | cursor state; a cached page is meaningless | +| **`TTL`, `PTTL`** | **time-dependent**: the answer changes with the clock, with no key write, so *nothing ever invalidates it*. The same failure class as a keyless command — permanently wrong, not briefly | +| **`TOUCH`** | **the side effect is the point**: it bumps LRU/LFU state, and a cache hit skips that entirely, so the command silently stops doing its job | +| **`PFCOUNT`** | **a read that writes**: it caches the computed cardinality back into the HLL header, so a cache hit skips a real mutation | + +`TOUCH` is worth dwelling on, because the codebase already contains the evidence that the two axes +diverge. Its entry in the category table reads: + +> `case RedisCommand.TOUCH: // technically bumps LRU/LFU state, but that's not a "real" side effect worth blocking retries over` + +Correct for retry, and exactly wrong for caching. That comment is the clearest single argument that +cacheability cannot be read off the retry category. + +**One I would question rather than accept.** `DUMP` was also flagged, but it looks *correctly* +invalidated: the payload is a deterministic function of the value, and the key is tracked, so a write +invalidates it properly. The case against is benefit rather than correctness — large payloads, rarely +re-read — which is what `NoClientCache` is for. Worth a second opinion before it joins a list of things +that are *unsafe*, since mixing "wrong" with "not worth it" makes the list harder to trust. #### Opt-out, not opt-in @@ -1391,6 +1418,22 @@ Tempting — it is a numeric range with gaps — but no: ladder orders one axis — is it safe to send again; cacheability asks another — will invalidation tell me when this changes. +#### Scripts: unresolved, and they stress the opt-out default + +`EVAL_RO` and `EVALSHA_RO` also default to `CommandRetryReadOnly`, so they pass the gate today. They are +**not** simply another row above, because **cacheability is a property of the script, not of the command +name**. Two `EVAL_RO` calls can differ entirely: one deterministic and perfectly cacheable, the next +reading `TIME` or `RANDOMKEY`. The library cannot know, and a blanket "scripts are excluded" throws away +the cacheable majority to catch the minority. + +The caller wrote the script, so the caller is the only party that *can* answer — which fits the opt-out +model. But it also stresses it: for scripts the default (cacheable) is **wrong** rather than merely +suboptimal, and being wrong by default is what opt-out is supposed to avoid. + +That reopens the explicit opt-in bit this section earlier set aside. It may be that scripts are the one +population genuinely needing it: everything else defaults to cacheable and is corrected by +`NoClientCache`, while a script defaults to *not* cacheable and opts in. **Unresolved**, deliberately. + #### Diagnosability The failure this design can still produce is silent and durable: something wrongly cached serves stale data @@ -1561,6 +1604,7 @@ reversals are the useful part. | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | | Fast byte test, `RespReader` only behind an attribute | Parse every reply | Attributes are the only thing that can precede a value, so a non-`\|` first byte *is* the content prefix - the cheap test is exact, and parsing is reserved for a branch that is in practice never taken (§6.12). | | Classify the reply with `RespReader` | Test `response[0]` | RESP3 attributes may precede any value, and nothing exempts errors from carrying them - so a first-byte test caches an error hidden behind metadata. Latent today because no server emits attributes, which is what makes it dangerous (§6.12). | +| Exclusions live in command metadata, beside the retry category | A `CommandFlags` bit | `CommandFlags.Category.cs` already classifies per enum value; cacheability is the same kind of fact about the same enum, and a flag would burden call sites with something we know (§6.9). | | Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | | Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | | Request combining deferred, with a counter | Build it now | A miss is a round trip on a multiplexed connection, not an arbitrary factory call, so the stampede economics differ by orders of magnitude. `RedundantFills` measures whether it is real without presupposing the design (§6.11). | @@ -2225,9 +2269,15 @@ member means a real sync path can arrive later without reshaping the API, and it and any async retention. Added while building the cache (§6.6-6.9): -- **Command metadata for cacheability.** Non-deterministic (`SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`) - and cursor-based (`SCAN`, `HSCAN`) commands are read-only and keyed, so the flag gates pass them. They - need a per-command fact in our own metadata, *not* a `CommandFlags` bit — see §6.9. +- **Command metadata for cacheability.** Read-only and keyed, so the flag gates pass them today: + non-deterministic (`SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`), cursor-based + (`SCAN`/`HSCAN`/`SSCAN`/`ZSCAN`), time-dependent (`TTL`, `PTTL`), side-effecting (`TOUCH`, `PFCOUNT`). + Wants a per-command fact beside the retry category in `CommandFlags.Category.cs`, *not* a `CommandFlags` + bit — see §6.9. `DUMP` was also proposed; I would challenge it, since it looks correctly invalidated, so + that is a benefit call rather than a safety one. +- **Scripts (`EVAL_RO`/`EVALSHA_RO`) are unresolved.** Cacheability is a property of the script, not the + command name, so neither a blanket exclusion nor cacheable-by-default is right. This is the case that + may justify the explicit opt-in bit §6.9 set aside. - **Do module reads register for invalidation?** If the server tracks keys only for core command dispatch, a keyed module read would be cached and never invalidated. Unresolved by the docs and worth five minutes against a real server with a module loaded; it decides whether §6.9's opt-out story needs From 8ad83b0bded65900f238aa7c979fb6157cd96ab0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:50:29 +0100 Subject: [PATCH 071/360] Scripts settled: RO scripts cacheable by default, caller opts out 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. --- design/interpolated-resp-writer.md | 36 +++++++++++++------ src/StackExchange.Redis/Enums/CommandFlags.cs | 6 ++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 24e7d341f..49932819c 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1418,7 +1418,7 @@ Tempting — it is a numeric range with gaps — but no: ladder orders one axis — is it safe to send again; cacheability asks another — will invalidation tell me when this changes. -#### Scripts: unresolved, and they stress the opt-out default +#### Scripts: cacheable by default, opt out explicitly `EVAL_RO` and `EVALSHA_RO` also default to `CommandRetryReadOnly`, so they pass the gate today. They are **not** simply another row above, because **cacheability is a property of the script, not of the command @@ -1426,13 +1426,27 @@ name**. Two `EVAL_RO` calls can differ entirely: one deterministic and perfectly reading `TIME` or `RANDOMKEY`. The library cannot know, and a blanket "scripts are excluded" throws away the cacheable majority to catch the minority. -The caller wrote the script, so the caller is the only party that *can* answer — which fits the opt-out -model. But it also stresses it: for scripts the default (cacheable) is **wrong** rather than merely -suboptimal, and being wrong by default is what opt-out is supposed to avoid. +**Resolved: cacheable by default, and opting out is the caller's job.** The reasoning is stronger than +"the caller knows best", which on its own would be a weak default: -That reopens the explicit opt-in bit this section earlier set aside. It may be that scripts are the one -population genuinely needing it: everything else defaults to cacheable and is corrected by -`NoClientCache`, while a script defaults to *not* cacheable and opts in. **Unresolved**, deliberately. +- **`EVAL`/`EVALSHA` are already excluded by the gate** — 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**, not a convention: a script that attempts a write under it errors. So the + caller has already made a declaration, and the server has already verified half of it. Defaulting those + to cacheable is a much smaller step than defaulting *scripts* to cacheable. + +**The risk worth documenting is not non-determinism — it is undeclared key access.** Invalidation tracks +the keys the script declares; a script that reads a key it did not declare in `KEYS[]` is not tracked +against that key, so it goes stale silently and stays stale. Declaring keys properly is already mandatory +in cluster, so the guidance aligns with existing good practice rather than adding a new rule. A +read-only script that also reads `TIME` or `RANDOMKEY` is possible but much rarer, and is the caller's to +notice. + +**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. A detector that is right most of the time is +worse than a clear rule, because people trust it; and the failure it would miss is the silent, durable +kind. #### Diagnosability @@ -1604,6 +1618,7 @@ reversals are the useful part. | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | | Fast byte test, `RespReader` only behind an attribute | Parse every reply | Attributes are the only thing that can precede a value, so a non-`\|` first byte *is* the content prefix - the cheap test is exact, and parsing is reserved for a branch that is in practice never taken (§6.12). | | Classify the reply with `RespReader` | Test `response[0]` | RESP3 attributes may precede any value, and nothing exempts errors from carrying them - so a first-byte test caches an error hidden behind metadata. Latent today because no server emits attributes, which is what makes it dangerous (§6.12). | +| `EVAL_RO`/`EVALSHA_RO` cacheable by default | Exclude all scripts; or detect by inspecting the script | `EVAL`/`EVALSHA` are already excluded by the gate, so the default applies only where the caller chose the `_RO` variant *and the server enforces it*. Inspecting the script loses to computed command names and `pcall`, and a detector that is usually right is worse than a rule (§6.9). | | Exclusions live in command metadata, beside the retry category | A `CommandFlags` bit | `CommandFlags.Category.cs` already classifies per enum value; cacheability is the same kind of fact about the same enum, and a flag would burden call sites with something we know (§6.9). | | Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | | Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | @@ -2275,9 +2290,10 @@ Added while building the cache (§6.6-6.9): Wants a per-command fact beside the retry category in `CommandFlags.Category.cs`, *not* a `CommandFlags` bit — see §6.9. `DUMP` was also proposed; I would challenge it, since it looks correctly invalidated, so that is a benefit call rather than a safety one. -- **Scripts (`EVAL_RO`/`EVALSHA_RO`) are unresolved.** Cacheability is a property of the script, not the - command name, so neither a blanket exclusion nor cacheable-by-default is right. This is the case that - may justify the explicit opt-in bit §6.9 set aside. +- ~~**Scripts (`EVAL_RO`/`EVALSHA_RO`) are unresolved.**~~ **Settled:** cacheable by default, caller opts + out with `NoClientCache`. `EVAL`/`EVALSHA` are already excluded by the gate, and `_RO` is server-enforced, + so the default applies to a narrow, self-declared population. The risk to document is *undeclared key + access*, not non-determinism — see §6.9. - **Do module reads register for invalidation?** If the server tracks keys only for core command dispatch, a keyed module read would be cached and never invalidated. Unresolved by the docs and worth five minutes against a real server with a module loaded; it decides whether §6.9's opt-out story needs diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index c3ba98087..ad2368e90 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -118,6 +118,12 @@ public enum CommandFlags /// command whose reads the server does not register for invalidation: it would otherwise be cached /// and never invalidated, and the library cannot know that on your behalf. /// + /// + /// A third: a read-only script (EVAL_RO/EVALSHA_RO) that reads a key it did not + /// declare in KEYS[]. Invalidation tracks the declared keys, so an undeclared read is never + /// invalidated and the result stays stale. Declaring every key touched is already required in + /// cluster; this is one more reason for it. + /// /// NoClientCache = 1 << 19, From 05c08bc55eecb198dd40ede622f0bc06d7946dba Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:51:00 +0100 Subject: [PATCH 072/360] Correct an overstatement: EVAL is excluded by DEFAULT, not absolutely 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. --- design/interpolated-resp-writer.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 49932819c..0efdf4985 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1429,13 +1429,20 @@ the cacheable majority to catch the minority. **Resolved: cacheable by default, and opting out is the caller's job.** The reasoning is stronger than "the caller knows best", which on its own would be a weak default: -- **`EVAL`/`EVALSHA` are already excluded by the gate** — 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. +- **`EVAL`/`EVALSHA` are excluded *by default*** — 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**, not a convention: a script that attempts a write under it errors. So the caller has already made a declaration, and the server has already verified half of it. Defaulting those to cacheable is a much smaller step than defaulting *scripts* to cacheable. +**A caller can still make a plain `EVAL` cacheable, and that is intended.** `WithDefaultCategory` is a +no-op when a category was already supplied, and `MaskRetryCategory` is in `Message.UserSelectableFlags` - +so passing `CommandRetryReadOnly` with an `EVAL` is honoured. That is not a hole: it is the same single +rule the whole gate rests on, *the declared retry category, whoever declared it*, applied without a +special case for scripts. Someone who declares a writing script read-only has already broken retry +semantics more severely than caching. + **The risk worth documenting is not non-determinism — it is undeclared key access.** Invalidation tracks the keys the script declares; a script that reads a key it did not declare in `KEYS[]` is not tracked against that key, so it goes stale silently and stays stale. Declaring keys properly is already mandatory @@ -1618,7 +1625,7 @@ reversals are the useful part. | >62 arguments reports "cannot report keys" | Report the first 62 | A partial list is worse than none: a caller tracking keys for invalidation would believe it complete and cache something it can never invalidate. | | Fast byte test, `RespReader` only behind an attribute | Parse every reply | Attributes are the only thing that can precede a value, so a non-`\|` first byte *is* the content prefix - the cheap test is exact, and parsing is reserved for a branch that is in practice never taken (§6.12). | | Classify the reply with `RespReader` | Test `response[0]` | RESP3 attributes may precede any value, and nothing exempts errors from carrying them - so a first-byte test caches an error hidden behind metadata. Latent today because no server emits attributes, which is what makes it dangerous (§6.12). | -| `EVAL_RO`/`EVALSHA_RO` cacheable by default | Exclude all scripts; or detect by inspecting the script | `EVAL`/`EVALSHA` are already excluded by the gate, so the default applies only where the caller chose the `_RO` variant *and the server enforces it*. Inspecting the script loses to computed command names and `pcall`, and a detector that is usually right is worse than a rule (§6.9). | +| `EVAL_RO`/`EVALSHA_RO` cacheable by default | Exclude all scripts; or detect by inspecting the script | `EVAL`/`EVALSHA` are excluded *by default*, so this applies only where the caller chose the `_RO` variant *and the server enforces it*. An explicit `CommandRetryReadOnly` on a plain `EVAL` is honoured, deliberately - one rule, no script special case. Inspecting the script loses to computed command names and `pcall` (§6.9). | | Exclusions live in command metadata, beside the retry category | A `CommandFlags` bit | `CommandFlags.Category.cs` already classifies per enum value; cacheability is the same kind of fact about the same enum, and a flag would burden call sites with something we know (§6.9). | | Errors never cached; nulls always | Cache errors too, or treat null as a miss | A cached reply must be a function of the tracked keys; an error need not be, so nothing would evict it and a transient failure becomes permanent. A null *is* a function of the key, and Redis tracks keys that do not exist, so negative caching is correct (§6.12). | | Cancellation applies only to the caller's await | HybridCache's extra token + waiter tracking | The fill populates a shared cache, so it has value once nobody is waiting - unlike an arbitrary external system, where it does not (§6.11). | @@ -2291,7 +2298,7 @@ Added while building the cache (§6.6-6.9): bit — see §6.9. `DUMP` was also proposed; I would challenge it, since it looks correctly invalidated, so that is a benefit call rather than a safety one. - ~~**Scripts (`EVAL_RO`/`EVALSHA_RO`) are unresolved.**~~ **Settled:** cacheable by default, caller opts - out with `NoClientCache`. `EVAL`/`EVALSHA` are already excluded by the gate, and `_RO` is server-enforced, + out with `NoClientCache`. `EVAL`/`EVALSHA` are excluded by default, `_RO` is server-enforced, so the default applies to a narrow, self-declared population. The risk to document is *undeclared key access*, not non-determinism — see §6.9. - **Do module reads register for invalidation?** If the server tracks keys only for core command From 7c76c2424fab91e4ea02b14b331c4f9f10995f7e Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 11:55:32 +0100 Subject: [PATCH 073/360] conn.GetDatabase().Strings.Get(key) - the minimal run, no wiring 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). --- design/interpolated-resp-writer.md | 17 ++++++++- src/StackExchange.Redis/RedisDatabase.cs | 27 ++++++++++++++ .../RespEndToEndTests.cs | 35 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 0efdf4985..b3accae57 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1311,7 +1311,22 @@ upstream buys - and it is scaffolding rather than a destination, since the `Mess to go away entirely in favour of execution life-cycle state. This is what made `RespEndToEndTests` possible: `target.Strings.Set/Get` against a real server, with the -legacy API cross-checking that the bytes landed. Before it, everything was validated against fakes - which +legacy API cross-checking that the bytes landed. + +**The minimal run now needs no wiring at all**, because `RedisDatabase.Context` is real: + +```csharp +var db = conn.GetDatabase(); +await db.Strings.Set(key, "marc"); +var value = await db.Strings.Get(key); +``` + +No cast, no executor, no context construction. The context is built once per database and cached - the +executor is a per-database object, and minting one per property access would allocate on a path meant not +to. `RedisBase.Context` still throws, so `IServer` and `ISubscriber` are untouched; `RedisDatabase` hides +it with `new`, which means the **interface mapping** must land on the derived member - if it ever landed on +the base, every extension member would throw, since they all reach the context through `IRespTarget`. That +is asserted rather than assumed. Before it, everything was validated against fakes - which proves the shape but never that a server accepts the bytes, since only framing was ever in question. Still open for a real transition: a cacheability predicate (Redis excludes `FT.*`, probabilistic and diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index cfe24f7aa..b11bf695f 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -24,6 +24,33 @@ internal RedisDatabase(ConnectionMultiplexer multiplexer, int db, object? asyncS public int Database { get; } + private Interpolated.RespContext _context; + private bool _haveContext; + + /// + /// + /// Built once and cached: the executor is a per-database object, and handing out a fresh one per + /// property access would allocate on a path meant to allocate nothing. The context itself is a + /// struct, so callers copy rather than share. + /// + public new Interpolated.RespContext Context + { + get + { + if (!_haveContext) + { + _context = new Interpolated.RespContext( + multiplexer.CommandMap, + database: Database, + serverType: multiplexer.ServerSelectionStrategy.ServerType) + .WithExecutor(new Interpolated.RespMessageExecutor(this, Database)); + _haveContext = true; + } + + return _context; + } + } + DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) { name = multiplexer.ClientName; diff --git a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs index 9ffaf8fe5..9e440949f 100644 --- a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs @@ -25,6 +25,41 @@ private static RespDatabase NewSurface(IConnectionMultiplexer conn, int db, Resp return new RespDatabase(context); } + [Fact] + public async Task TheMinimalRunNeedsNoWiringAtAll() + { + await using var conn = Create(); + var key = Me(); + var db = conn.GetDatabase(); + await db.KeyDeleteAsync(key); + + // RedisDatabase.Context HIDES the throwing RedisBase.Context with 'new', so the interface mapping + // has to land on the derived one - if it ever landed on the base, every extension member would + // throw, since they all reach the context through IRespTarget + Assert.NotNull(((IRespTarget)db).Context.Executor); + + // no casts, no executor, no context construction - GetDatabase() is already an IRespTarget + Assert.True(await db.Strings.Set(key, "marc")); + Assert.Equal("marc", await db.Strings.Get(key)); + Assert.Equal("marc", await db.StringGetAsync(key)); + } + + [Fact] + public async Task TheContextCarriesTheDatabaseIndex() + { + await using var conn = Create(); + var key = Me(); + var db = conn.GetDatabase(3); + await db.KeyDeleteAsync(key); + + Assert.Equal(3, db.Context.Database); + Assert.True(await db.Strings.Set(key, "on-three")); + + // it really went to db 3, not db 0 + Assert.Equal("on-three", await conn.GetDatabase(3).StringGetAsync(key)); + Assert.True((await conn.GetDatabase(0).StringGetAsync(key)).IsNull); + } + [Fact] public async Task SetAndGetAgainstARealServer() { From b104b76a9a72fe430fd92d2398c3c70088202e9d Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 12:05:39 +0100 Subject: [PATCH 074/360] Measure RespContext before optimising the accessor 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. --- design/interpolated-resp-writer.md | 26 +++++++++++++++++++ .../RespSurfaceTests.cs | 14 ++++++++++ 2 files changed, 40 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index b3accae57..c4eb16461 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -544,6 +544,28 @@ If the context is unavailable for some path, command resolution can be **deferre handler stores the `RedisCommand` and the concrete implementation resolves it at `Close`/`Execute`. That is the same mechanism §3.2 already needs, and it keeps mocks working. +#### How big is it, and should `.Context` be a field? + +Measured: **`RespContext` is 64 bytes**, which is past the point where the JIT keeps a struct in registers, +so a by-value copy is a real one. That prompted the question of whether the grouping structs should expose +their context as a public *field* rather than a property, to avoid a copy on `strings.Context`. + +**No — but the measurement points at something better.** The 64 bytes break down as 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 the `Strings`, `Hashes` +and every other data-type group never touch. + +So the fix is to **shrink the thing being copied**, not to dodge one copy of it. `ChannelPrefix` is the +obvious candidate for the services slot that already exists for optional capabilities (§6.7), or for being +stored as the `byte[]` it is normalised to - the writer only ever wants the bytes. That would take the +context to 48 bytes and help *every* copy, including the ones inside `Send` on the hot path, rather than +only the rare external `.Context` read. + +Against the public field specifically: the JIT inlines a trivial getter, so partial uses like +`strings.Context.Database` are usually forwarded anyway; and a public field locks the representation, +which is precisely what the paragraph above wants to change. (Style is not the objection - +`.editorconfig` sets SA1401 to `silent`, so public fields are allowed here.) + ### 3.4 The context is not a new idea — it is `MessageWriter`'s parameter list Long term this replaces `MessageWriter`, and that is the clearest way to see what the context is for: @@ -2332,6 +2354,10 @@ Added while building the cache (§6.6-6.9): - **Bounding the cache.** Invalidated entries linger until `Sweep`, and the key table grows with distinct keys seen. Both need a size bound; both fail closed, so bounding is safe (§6.6). +- **Shrink `RespContext`.** 64 bytes, of which `RedisChannel ChannelPrefix` is 16 - a quarter of every + copy, for something only pub/sub uses. Moving it into the services slot, or storing the `byte[]` the + writer actually wants, takes it to 48. See §3.3. + - **Static key bitmaps.** For fixed-arity commands the key positions are statically known, so the JIT may constant-fold the bitmap when the Append chain inlines. Not to be designed around, but the structure permits it and an analyzer could emit the constant if it matters. diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index 7ef115f96..aa8468286 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -36,6 +36,20 @@ public ValueTask SendAsync(RespRequest request, CancellationToken c private static RespDatabase Target(FakeExecutor executor, RespClientCache? cache = null) => new(new RespContext().WithExecutor(executor).WithCache(cache)); + [Fact] + public void AGroupStructCostsNothingOverTheContext() + { + // RespStrings holds exactly one RespContext, so it is the same size - the wrapper IS the pun, and + // this is what says so. If it ever diverges, someone has added a field to a grouping type. + Assert.Equal( + System.Runtime.CompilerServices.Unsafe.SizeOf(), + System.Runtime.CompilerServices.Unsafe.SizeOf()); + + // 64 bytes as of writing, of which RedisChannel ChannelPrefix is 16 - see design notes 3.3 on + // shrinking it. Asserted loosely: the point is that it is past register size, not the exact value. + Assert.True(System.Runtime.CompilerServices.Unsafe.SizeOf() > 32); + } + [Fact] public async Task SetAndGetThroughTheGroupedSurface() { From ac6e98ff7b2edb57d623e0eb6d938fc646a21b04 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 12:08:55 +0100 Subject: [PATCH 075/360] ChannelPrefix moves into the service slot: RespContext 64 -> 48 bytes 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. --- design/interpolated-resp-writer.md | 24 ++++--- .../Interpolated/RespContext.cs | 62 ++++++++++++++++--- .../RespSurfaceTests.cs | 37 ++++++++++- 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index c4eb16461..36079edc6 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -555,11 +555,21 @@ a `CancellationToken` (8), `Database` + `ServerType` (8), and **`RedisChannel Ch quarter of every context copy is a channel prefix that only pub/sub uses and that the `Strings`, `Hashes` and every other data-type group never touch. -So the fix is to **shrink the thing being copied**, not to dodge one copy of it. `ChannelPrefix` is the -obvious candidate for the services slot that already exists for optional capabilities (§6.7), or for being -stored as the `byte[]` it is normalised to - the writer only ever wants the bytes. That would take the -context to 48 bytes and help *every* copy, including the ones inside `Send` on the hot path, rather than -only the rare external `.Context` read. +So the fix is to **shrink the thing being copied**, not to dodge one copy of it. **Done:** `ChannelPrefix` +moved into the services slot that already existed for optional capabilities (§6.7), taking the context +from **64 bytes to 48** - a quarter off *every* copy, including the ones inside `Send` on the hot path, +rather than only the rare external `.Context` read. Resolving it now costs a type test, paid only by code +that actually writes a channel. + +**The slot became a chain to make this work.** One service was enough while the cache was the only one; +two are not. A `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 two pieces of code rather than adding them: +"replace" needs none, because a later add shadows an earlier one; and "remove" needs none, because setting +a prefix back to `default` shadows it with an empty one that reads as absent. An array would have to be +copied on every add; a link is one allocation, immutable, and shared by every context clone. + +It is allocated per context *configuration* and never per command - and only from the second service +onwards, since a context with exactly one keeps the bare object and never sees the chain at all. Against the public field specifically: the JIT inlines a trivial getter, so partial uses like `strings.Context.Database` are usually forwarded anyway; and a public field locks the representation, @@ -2354,10 +2364,6 @@ Added while building the cache (§6.6-6.9): - **Bounding the cache.** Invalidated entries linger until `Sweep`, and the key table grows with distinct keys seen. Both need a size bound; both fail closed, so bounding is safe (§6.6). -- **Shrink `RespContext`.** 64 bytes, of which `RedisChannel ChannelPrefix` is 16 - a quarter of every - copy, for something only pub/sub uses. Moving it into the services slot, or storing the `byte[]` the - writer actually wants, takes it to 48. See §3.3. - - **Static key bitmaps.** For fixed-arity commands the key positions are statically known, so the JIT may constant-fold the bitmap when the Append chain inlines. Not to be designed around, but the structure permits it and an analyzer could emit the constant if it matters. diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index bf3922a5e..07b143593 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -41,12 +41,13 @@ internal RespContext( { _commandMap = commandMap; _keyPrefix = keyPrefix; // normalise to bytes ONCE; the conversion can allocate for a string-backed key - ChannelPrefix = channelPrefix; Database = database; ServerType = serverType; CancellationToken = cancellationToken; Executor = executor; - _services = services; + _services = channelPrefix.IsNull + ? services + : ServiceLink.Add(services, new ChannelPrefixService(channelPrefix)); } /// Where commands composed from this context are sent; null if none is configured. @@ -58,6 +59,40 @@ internal RespContext( private readonly object? _services; + /// Carries a channel prefix in the service slot; a class, so the struct is not boxed loose. + private sealed class ChannelPrefixService(RedisChannel channel) + { + internal RedisChannel Channel { get; } = channel; + } + + /// Services in one slot, as a chain: a service plus whatever was already there. + /// + /// + /// Prepending, so the most recently added wins by lookup order - which means "replace" needs no + /// code at all, and neither does removal: setting a prefix back to default simply shadows + /// the old one with an empty one. An array would be copied on every add; a link is one small + /// allocation, and the chain is immutable so every context clone shares it. + /// + /// + /// Allocated per context configuration, never per command - and only from the second service + /// onwards, since a context with exactly one keeps the bare object and never sees this. + /// + /// + private sealed class ServiceLink(object service, object tail) : IServiceProvider + { + public object? GetService(Type serviceType) + { + if (serviceType.IsInstanceOfType(service)) return service; + + return tail is IServiceProvider provider + ? provider.GetService(serviceType) + : serviceType.IsInstanceOfType(tail) ? tail : null; + } + + internal static object Add(object? existing, object service) + => existing is null ? service : new ServiceLink(service, existing); + } + /// /// Obtain a service attached to this context, if any. /// @@ -148,7 +183,14 @@ internal bool TryResolveCommand(ReadOnlySpan name, out ReadOnlySpan internal ReadOnlySpan KeyPrefixSpan => _keyPrefix; /// The prefix applied to channels written through this context. - public RedisChannel ChannelPrefix { get; } + /// + /// Held as a service rather than a field. As a field it was a - + /// 16 bytes, a quarter of the whole context - carried on every copy for pub/sub's benefit alone, + /// while every data-type group ignored it. Resolving it costs a type test, paid only by code that + /// actually writes a channel. See design notes section 3.3. + /// + public RedisChannel ChannelPrefix + => TryGetService(out var prefix) ? prefix.Channel : default; /// The database index; part of cache identity, and NOT part of the rendered frame. public int Database { get; } @@ -167,12 +209,12 @@ public RespContext WithCancellationToken(CancellationToken cancellationToken) /// A copy of this context targeting a different database. /// The database index. public RespContext WithDatabase(int database) - => new(CommandMap, KeyPrefix, ChannelPrefix, database, ServerType, CancellationToken, Executor, _services); + => new(CommandMap, KeyPrefix, default, database, ServerType, CancellationToken, Executor, _services); /// A copy of this context with a different server type. /// The server type. public RespContext WithServerType(ServerType serverType) - => new(CommandMap, KeyPrefix, ChannelPrefix, Database, serverType, CancellationToken, Executor, _services); + => new(CommandMap, KeyPrefix, default, Database, serverType, CancellationToken, Executor, _services); /// /// Returns a context whose keys are prefixed. This is what replaces wrapping the database in a @@ -183,7 +225,7 @@ public RespContext WithKeyPrefix(RedisKey keyPrefix) => new( CommandMap, _keyPrefix is null ? keyPrefix : RedisKey.WithPrefix(_keyPrefix, keyPrefix), - ChannelPrefix, + default, Database, ServerType, CancellationToken, @@ -193,17 +235,19 @@ public RespContext WithKeyPrefix(RedisKey keyPrefix) /// A copy of this context with a different channel prefix. /// The prefix to apply to channels. public RespContext WithChannelPrefix(RedisChannel channelPrefix) - => new(CommandMap, KeyPrefix, channelPrefix, Database, ServerType, CancellationToken, Executor, _services); + // a null prefix shadows any earlier one with an empty service rather than removing it: the + // chain stays append-only, and ChannelPrefix reads default from it either way + => WithServices(ServiceLink.Add(_services, new ChannelPrefixService(channelPrefix))); /// A copy of this context that sends through . /// The executor to send through. internal RespContext WithExecutor(IRespExecutor? executor) - => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, executor, _services); + => new(CommandMap, _keyPrefix, default, Database, ServerType, CancellationToken, executor, _services); /// A copy of this context carrying . /// The service, or an , or null. public RespContext WithServices(object? services) - => new(CommandMap, _keyPrefix, ChannelPrefix, Database, ServerType, CancellationToken, Executor, services); + => new(CommandMap, _keyPrefix, default, Database, ServerType, CancellationToken, Executor, services); /// A copy of this context that consults . /// The cache to consult, or null for none. diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index aa8468286..e325c14dc 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -45,9 +45,40 @@ public void AGroupStructCostsNothingOverTheContext() System.Runtime.CompilerServices.Unsafe.SizeOf(), System.Runtime.CompilerServices.Unsafe.SizeOf()); - // 64 bytes as of writing, of which RedisChannel ChannelPrefix is 16 - see design notes 3.3 on - // shrinking it. Asserted loosely: the point is that it is past register size, not the exact value. - Assert.True(System.Runtime.CompilerServices.Unsafe.SizeOf() > 32); + // 48 bytes once ChannelPrefix moved into the service slot - it was 64, of which RedisChannel was + // 16, carried on every copy for pub/sub's benefit alone. See design notes 3.3. + Assert.Equal(48, System.Runtime.CompilerServices.Unsafe.SizeOf()); + } + + [Fact] + public void ServicesComposeRatherThanReplaceEachOther() + { + using var cache = new RespClientCache(); + var ctx = new RespContext() + .WithCache(cache) + .WithChannelPrefix(RedisChannel.Literal("app:")); + + // the second service must not evict the first - the slot is a chain, not a variable + Assert.Same(cache, ctx.Cache); + Assert.Equal("app:", (string?)ctx.ChannelPrefix); + + // and the newest of a given type wins, by lookup order, with no replace logic + var rebound = ctx.WithChannelPrefix(RedisChannel.Literal("other:")); + Assert.Equal("other:", (string?)rebound.ChannelPrefix); + Assert.Same(cache, rebound.Cache); + + // setting it back to nothing shadows rather than removes, and still reads as absent + Assert.True(rebound.WithChannelPrefix(default).ChannelPrefix.IsNull); + } + + [Fact] + public void ChannelPrefixSurvivesUnrelatedClones() + { + var ctx = new RespContext().WithChannelPrefix(RedisChannel.Literal("app:")).WithDatabase(4).WithKeyPrefix("t7:"); + + // it travels in services now, so every With* has to carry it without naming it + Assert.Equal("app:", (string?)ctx.ChannelPrefix); + Assert.Equal(4, ctx.Database); } [Fact] From d650b8b8d75a3ce71c1ce0d790f2c77794276748 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 12:21:42 +0100 Subject: [PATCH 076/360] A command is one expression; flags are cumulative 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. 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. --- design/interpolated-resp-writer.md | 20 +++++ .../Enums/CommandFlags.Category.cs | 27 ++++-- .../Interpolated/RespClientCache.cs | 2 +- .../Interpolated/RespExecutor.cs | 75 ++++++++++++++--- .../Interpolated/RespSurface.cs | 46 +++++++---- .../PublicAPI/PublicAPI.Unshipped.txt | 16 ++-- .../RedisDatabase.Strings.cs | 2 +- src/StackExchange.Redis/RedisDatabase.cs | 32 ++++---- src/StackExchange.Redis/RedisServer.cs | 82 +++++++++---------- src/StackExchange.Redis/ServerEndPoint.cs | 2 +- .../SortedSetAddMessage.cs | 2 +- .../RespClientCacheTests.cs | 35 ++++---- .../RespSurfaceTests.cs | 52 ++++++++++++ 13 files changed, 276 insertions(+), 117 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 36079edc6..cc757c808 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1263,6 +1263,26 @@ That orchestration internalises three lifetimes, in descending order of how easy | Payload retained across the parse, released in a `finally` | `Parse` is called inside the window | | The request frame consumed on **every** path | `ref RespFrame`, neutered whether it became a key or not | +**A command is one expression.** `SendAsync` takes the interpolated string directly - the `ref` is implied, +exactly as `Execute` already does - with **flags before the handler** so the handler can be omitted: + +```csharp +public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags.None) + => ctx.SendAsync($"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandRetryReadOnly)); +``` + +An omitted handler is resolved from `TResult` (`RespHandlers.Inbuilt`), with a throw naming the type if +there is none - at the call site, not when a reply arrives. `TResult` must be explicit, because C# infers +type arguments from arguments and never from a return type. + +**Flags are cumulative, and that is a correctness point rather than a style one.** The category must come +from `WithRetryCategory` at the call site, *not* from the parameter's default value. With +`flags = CommandRetryReadOnly` as a default, a caller passing `CommandFlags.FireAndForget` would silently +**replace** the category with nothing - losing both the retry semantics and, now, cacheability. People +expect flags to add. `WithRetryCategory` is first-wins, so an explicitly named category still beats ours. +`CommandFlagsExtensions` became public for this: an external command surface cannot express a default +category without it. Pinned by tests, since the failure is silent. + None of the three is visible at the call site, which reduces to: ```csharp diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs index d13417492..9e0d5a53c 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs @@ -1,8 +1,21 @@ namespace StackExchange.Redis; -internal static class CommandFlagsExtensions +/// +/// Helpers for composing . +/// +public static class CommandFlagsExtensions { - public static CommandFlags WithCategory(this CommandFlags flags, CommandFlags category) + /// + /// Apply a retry category, unless the caller already chose one. + /// + /// The caller's flags. + /// The category this command would use by default. + /// + /// Public because a command surface outside this library needs it: flags are cumulative, so a + /// caller passing must not thereby lose the command's retry + /// category. Put the category here rather than in a parameter default, or the two cannot coexist. + /// + public static CommandFlags WithRetryCategory(this CommandFlags flags, CommandFlags category) { // CommandServerSpecific is an orthogonal flag rather than part of the severity ladder, so it // is always additive - the caller choosing a retry category doesn't make a cursor-bearing @@ -17,7 +30,7 @@ public static CommandFlags WithCategory(this CommandFlags flags, CommandFlags ca /// The retry category implied by an existence condition applied to an otherwise unconditional write; /// means "no opinion", leaving the per-command default in place. /// - public static CommandFlags AsRetryCategory(this When when) => when switch + internal static CommandFlags AsRetryCategory(this When when) => when switch { // NX/XX make the write conditional: a replay either no-ops or fails, and either way the // end-state matches the first attempt. @@ -30,20 +43,20 @@ public static CommandFlags WithCategory(this CommandFlags flags, CommandFlags ca /// something on the node that issued it (and, for the per-key variants, against that node's encoding of the /// object), so it is node-affine; a fresh iteration from the origin cursor can start anywhere. /// - public static CommandFlags WithScanCursorCategory(this CommandFlags flags, in RedisValue cursor) - => flags.WithCategory(cursor == RedisBase.CursorUtils.Origin + internal static CommandFlags WithScanCursorCategory(this CommandFlags flags, in RedisValue cursor) + => flags.WithRetryCategory(cursor == RedisBase.CursorUtils.Origin ? CommandFlags.CommandRetryReadOnly : CommandFlags.CommandRetryReadOnly | Message.CommandServerSpecific); /// - public static CommandFlags AsRetryCategory(this ExpireWhen when) => when switch + internal static CommandFlags AsRetryCategory(this ExpireWhen when) => when switch { // NX/XX/GT/LT; GT/LT are monotone, so re-applying converges on the same deadline ExpireWhen.Always => CommandFlags.None, _ => CommandFlags.CommandRetryWriteChecked, }; - public static CommandFlags WithDefaultCategory(this CommandFlags flags, RedisCommand command) + internal static CommandFlags WithDefaultCategory(this CommandFlags flags, RedisCommand command) { if ((flags & Message.MaskRetryCategory) is 0) { diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 43ef64516..88339ada2 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -237,7 +237,7 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, deps[i] = new Dependency(node, generation); } - fill = new RespFill(frame.Detach(), database, deps); + fill = new RespFill(frame.Detach(flags), database, deps); return true; } diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 39043fb1a..b30c8fdcf 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using RESPite; @@ -71,6 +72,13 @@ public interface IRespHandler /// called the executor would have to sit above dispatch and know how to send. /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + + // RS0027 wants the overload carrying optional parameters to have the most parameters. It is guidance + // aimed at ambiguity when parameters are added later, and it does not apply here: the two overloads + // differ in the TYPE of their second parameter - an interpolated-string handler versus a rendered + // frame - so no call can be ambiguous between them, whatever is added. Both are public because the + // frame form is what Compose produces, and that path is public. + [SuppressMessage("ApiDesign", "RS0027:API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "Overloads differ by parameter type; ambiguity is impossible")] public static class RespExecutor { /// @@ -79,12 +87,12 @@ public static class RespExecutor /// What parsing the reply produces. /// The context to send through; supplies the executor, cache and cancellation. /// The rendered request; consumed by this call on every path. - /// Turns the reply into a result. /// /// The command's flags. Caching additionally requires a declared retry category no more severe than /// ; see /// . /// + /// Turns the reply into a result. /// /// /// is deliberately not optional. Every IDatabase method in @@ -98,10 +106,10 @@ public static class RespExecutor /// and released in a finally; and the request is consumed on every path. /// public static TResult Send( - this in RespContext context, + this RespContext context, ref RespFrame request, - IRespHandler handler, - CommandFlags flags) + CommandFlags flags, + IRespHandler handler) { if (handler is null) throw new ArgumentNullException(nameof(handler)); var executor = context.Executor ?? ThrowNoExecutor(ref request); @@ -132,7 +140,7 @@ public static TResult Send( } // the executor may need the bytes past this call, so hand it something it can retain - var owned = request.Detach(); + var owned = request.Detach(flags); try { var response = executor.Send(owned); @@ -151,7 +159,7 @@ public static TResult Send( } } - /// + /// /// The context to send through; supplies the executor, cache and cancellation. /// The rendered request; consumed by this call on every path. /// Turns the reply into a result. @@ -164,10 +172,10 @@ public static TResult Send( /// state machine, no Task. /// public static ValueTask SendAsync( - this in RespContext context, + this RespContext context, ref RespFrame request, - IRespHandler handler, - CommandFlags flags) + CommandFlags flags, + IRespHandler handler) { if (handler is null) throw new ArgumentNullException(nameof(handler)); var executor = context.Executor ?? ThrowNoExecutor(ref request); @@ -187,7 +195,54 @@ public static ValueTask SendAsync( } } - return AwaitUncached(executor, request.Detach(), handler, cancellationToken); + return AwaitUncached(executor, request.Detach(flags), handler, cancellationToken); + } + + /// + /// Compose and send in one expression: ctx.SendAsync<RedisValue>($"{cmd}{key}", flags). + /// + /// What parsing the reply produces. + /// The context to send through. + /// The command, written as an interpolated string. + /// The command's flags. + /// + /// Turns the reply into a result; omit it to use the built-in handler for . + /// + /// + /// + /// The ref is implied: the compiler builds the handler from the interpolated string and + /// passes it by reference, exactly as RespContext.Execute already does. So a whole command + /// is one expression, which is the point of the surface. + /// + /// + /// Flags come before the handler so the handler can be omitted. + /// must then be given explicitly - C# does not infer type arguments from a return type - which is + /// why this reads SendAsync<RedisValue> rather than inferring it. + /// + /// + public static ValueTask SendAsync( + this RespContext context, + [InterpolatedStringHandlerArgument(nameof(context))] ref RespCommandHandler request, + CommandFlags flags, + IRespHandler? handler = null) + { + var frame = request.Complete(); + return SendAsync(context, ref frame, flags, handler ?? RespHandlers.Inbuilt.Require()); + } + + /// + /// The context to send through. + /// The command, written as an interpolated string. + /// The command's flags. + /// Turns the reply into a result; omit for the built-in one. + public static TResult Send( + this RespContext context, + [InterpolatedStringHandlerArgument(nameof(context))] ref RespCommandHandler request, + CommandFlags flags, + IRespHandler? handler = null) + { + var frame = request.Complete(); + return Send(context, ref frame, flags, handler ?? RespHandlers.Inbuilt.Require()); } [DoesNotReturn] diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 3dfe16a92..86b085e99 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -69,6 +69,31 @@ public static class RespHandlers /// Reads a simple-string reply as success. public static IRespHandler Ok { get; } = new OkHandler(); + /// The handler used when a call does not name one; resolved by result type. + /// The result type. + /// + /// This is what lets a command surface be one expression: most commands want the obvious handler + /// for their result type, and naming it every time is noise. A type with no registered handler + /// throws where the call is written, saying which type and what to do - not at the point the reply + /// arrives. + /// + internal static class Inbuilt + { + internal static readonly IRespHandler? Handler = Resolve(); + + internal static IRespHandler Require() + => Handler ?? throw new InvalidOperationException( + $"No built-in RESP handler for '{typeof(T).Name}'; pass one explicitly."); + + private static IRespHandler? Resolve() + { + object? handler = null; + if (typeof(T) == typeof(RedisValue)) handler = Value; + else if (typeof(T) == typeof(bool)) handler = Ok; + return (IRespHandler?)handler; + } + } + private sealed class ValueHandler : IRespHandler { public RedisValue Parse(ReadOnlySpan response) @@ -118,26 +143,17 @@ public static class RespSurface /// GET. /// The key to read. /// Command flags. - public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags.CommandRetryReadOnly) - { - var ctx = strings.Context; - var frame = ctx.Execute($"{RedisCommand.GET}{key}"); - return ctx.SendAsync(ref frame, RespHandlers.Value, flags); - } + public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly)); /// SET. /// The key to write. /// The value to write. /// Command flags. - public ValueTask Set( - RedisKey key, - RedisValue value, - CommandFlags flags = CommandFlags.CommandRetryWriteLastWins) - { - var ctx = strings.Context; - var frame = ctx.Execute($"{RedisCommand.SET}{key}{value}"); - return ctx.SendAsync(ref frame, RespHandlers.Ok, flags); - } + public ValueTask Set(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.SET}{key}{value}", flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins)); } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 57411e17f..0e88c0242 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.CommandFlags +StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.IRespTarget @@ -135,8 +136,8 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings) -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! @@ -145,14 +146,17 @@ StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.C [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this System.ReadOnlySpan name) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this string! name, bool preform = false) -> StackExchange.Redis.Interpolated.RespCommand -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> TResult -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this in StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.Interpolated.IRespHandler! handler, StackExchange.Redis.CommandFlags flags) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Ok.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryReadOnly) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void +static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackExchange.Redis.CommandFlags flags, StackExchange.Redis.CommandFlags category) -> StackExchange.Redis.CommandFlags diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index 114d971f8..59da25ef3 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -128,7 +128,7 @@ internal Message GetStringSetMessage(in RedisKey key, in RedisValue value, Expir case ValueCondition.ConditionKind.DigestEquals: case ValueCondition.ConditionKind.DigestNotEquals: // SET ... IFEQ/IFNE/IFDEQ/IFDNE is a compare-and-set, not a blind overwrite - return Message.Create(Database, flags.WithCategory(when.RetryCategory), RedisCommand.SET, key, value, expiry, when); + return Message.Create(Database, flags.WithRetryCategory(when.RetryCategory), RedisCommand.SET, key, value, expiry, when); default: when.ThrowInvalidOperation(operation); goto case ValueCondition.ConditionKind.Always; // not reached diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index b11bf695f..3f971010b 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -299,7 +299,7 @@ internal Message GetGeoRadiusMessage(in RedisKey key, RedisValue? member, double // GEORADIUS[BYMEMBER] defaults to a write category because of the STORE/STOREDIST variants, which // we can't see through Execute; this typed API never emits them, so it is always a pure query. - flags = flags.WithCategory(CommandFlags.CommandRetryReadOnly); + flags = flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly); return Message.Create(Database, flags, command, key, redisValues.ToArray()); } @@ -477,7 +477,7 @@ internal Message GetHashFieldExpireMessage(RedisKey key, long milliseconds, Expi // H[P]EXPIRE[AT] ... NX/XX/GT/LT is a conditional write, exactly as for the key-level EXPIRE; // a bare one keeps the last-wins default - flags = flags.WithCategory(when.AsRetryCategory()); + flags = flags.WithRetryCategory(when.AsRetryCategory()); var values = when switch { @@ -566,7 +566,7 @@ internal Message HashFieldGetAndSetExpiryMessage(in RedisKey key, in RedisValue /// mutates the TTL, making it a write. /// private static CommandFlags WithGetExCategory(CommandFlags flags, int expiryTokenCount) - => expiryTokenCount == 0 ? flags : flags.WithCategory(CommandFlags.CommandRetryWriteLastWins); + => expiryTokenCount == 0 ? flags : flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); private Message HashFieldGetAndSetExpiryMessage(in RedisKey key, RedisValue[] hashFields, Expiration expiry, CommandFlags flags) { @@ -2175,7 +2175,7 @@ internal static RedisCommand ForReadOnlyScript(CommandMap map, RedisCommand read return readOnlyCommand; } - flags = flags.WithCategory(CommandFlags.CommandRetryReadOnly); + flags = flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly); return readOnlyCommand == RedisCommand.EVALSHA_RO ? RedisCommand.EVALSHA : RedisCommand.EVAL; } @@ -4283,7 +4283,7 @@ internal Message GetCopyMessage(in RedisKey sourceKey, RedisKey destinationKey, { // without REPLACE, COPY fails if the destination exists, so a replay is a no-op (the per-command // default); with REPLACE it becomes an unconditional overwrite of the destination. - if (replace) flags = flags.WithCategory(CommandFlags.CommandRetryWriteLastWins); + if (replace) flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); return destinationDatabase switch { @@ -4339,7 +4339,7 @@ private Message GetExpiryMessage( server = null; // EXPIRE ... NX/XX/GT/LT is a conditional write; a bare EXPIRE keeps the last-wins default - flags = flags.WithCategory(when.AsRetryCategory()); + flags = flags.WithRetryCategory(when.AsRetryCategory()); if ((milliseconds % 1000) != 0) { @@ -4526,7 +4526,7 @@ internal sealed class MultiStreamReadGroupCommandMessage : Message // XREADGROUP private readonly TimeSpan? claimMinIdleTime; public MultiStreamReadGroupCommandMessage(int db, CommandFlags flags, StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream, bool noAck, TimeSpan? claimMinIdleTime, int? maxCount = null, int? maxSize = null) - : base(db, flags.WithCategory(GetStreamReadGroupCategory(streamPositions, claimMinIdleTime)), RedisCommand.XREADGROUP) + : base(db, flags.WithRetryCategory(GetStreamReadGroupCategory(streamPositions, claimMinIdleTime)), RedisCommand.XREADGROUP) { if (streamPositions == null) throw new ArgumentNullException(nameof(streamPositions)); if (streamPositions.Length == 0) throw new ArgumentOutOfRangeException(nameof(streamPositions), "streamOffsetPairs must contain at least one item."); @@ -4794,7 +4794,7 @@ internal Message GetSortMessage(RedisKey destination, RedisKey key, long skip, l // SORT is categorized read-only by default (the common case), but the STORE variant writes the // destination key; without this, a replay of a SORT ... STORE would be treated as a harmless read. - if (!destination.IsNull) flags = flags.WithCategory(CommandFlags.CommandRetryWriteLastWins); + if (!destination.IsNull) flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); // If SORT_RO is not available, we cannot issue the command to a read-only replica if (command == RedisCommand.SORT) @@ -5132,7 +5132,7 @@ internal Message GetStreamAddMessage(in RedisKey key, in StreamAddOptions option /// private static CommandFlags GetStreamAddCategory(CommandFlags flags, in StreamAddOptions options) => (options.IdempotentId.ArgCount != 0 || !IsServerAssignedId(options.EntryId)) - ? flags.WithCategory(CommandFlags.CommandRetryWriteChecked) + ? flags.WithRetryCategory(CommandFlags.CommandRetryWriteChecked) : flags; /// @@ -5234,7 +5234,7 @@ internal Message GetStreamClaimMessage(RedisKey key, RedisValue consumerGroup, R /// than caller data (raising it to "accumulating" would stop these being retried at all by default). /// private static CommandFlags WithJustIdCategory(CommandFlags flags, bool justId) - => justId ? flags.WithCategory(CommandFlags.CommandRetryWriteChecked) : flags; + => justId ? flags.WithRetryCategory(CommandFlags.CommandRetryWriteChecked) : flags; private Message GetStreamCreateConsumerGroupMessage(RedisKey key, RedisValue groupName, RedisValue? position = null, bool createStream = true, CommandFlags flags = CommandFlags.None) { @@ -5387,7 +5387,7 @@ private sealed class SingleStreamReadGroupCommandMessage : Message.CommandKeyBas private readonly TimeSpan? claimMinIdleTime; public SingleStreamReadGroupCommandMessage(int db, CommandFlags flags, RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue afterId, int? count, bool noAck, TimeSpan? claimMinIdleTime) - : base(db, flags.WithCategory(GetStreamReadGroupCategory(afterId, claimMinIdleTime)), RedisCommand.XREADGROUP, key) + : base(db, flags.WithRetryCategory(GetStreamReadGroupCategory(afterId, claimMinIdleTime)), RedisCommand.XREADGROUP, key) { if (count.HasValue && count <= 0) { @@ -5628,14 +5628,14 @@ internal static RedisCommand SelectBitFieldCommand(bool allGet, bool anyIncremen if (allGet) { // nothing to replay, whichever of the two commands we end up issuing - flags = flags.WithCategory(CommandFlags.CommandRetryReadOnly); + flags = flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly); return readOnlyAvailable ? RedisCommand.BITFIELD_RO : RedisCommand.BITFIELD; } if (!anyIncrement) { // SET is positional, so a replay lands on the same value; only INCRBY compounds - flags = flags.WithCategory(CommandFlags.CommandRetryWriteLastWins); + flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); } return RedisCommand.BITFIELD; @@ -5725,7 +5725,7 @@ internal Message GetStringSetMessage( // NX/XX make this a *conditional* write, whichever spelling we end up emitting below // (SETNX, or SET with NX/XX); a bare SET keeps the per-command "last wins" default. - flags = flags.WithCategory(when.AsRetryCategory()); + flags = flags.WithRetryCategory(when.AsRetryCategory()); if (value.IsNull) return Message.Create(Database, flags, RedisCommand.DEL, key); @@ -5787,7 +5787,7 @@ private Message GetStringSetAndGetMessage( // as GetStringSetMessage: NX/XX make the write conditional. Note that the GET operand makes the // *reply* non-idempotent on a replay (you get back what you just wrote), but that is equally // true of GETSET, which we categorize on its keyspace effect alone; stay consistent. - flags = flags.WithCategory(when.AsRetryCategory()); + flags = flags.WithRetryCategory(when.AsRetryCategory()); if (value.IsNull) return Message.Create(Database, flags, RedisCommand.GETDEL, key); @@ -6022,7 +6022,7 @@ public ScriptLoadMessage(CommandFlags flags, string script) // could be retried across endpoints is IServer.ScriptLoad, and WithRetry wraps IDatabaseAsync // only. The internal load-then-EVALSHA pairing in ScriptEvalMessage.GetMessages is written to // one connection as a unit and so is never independently re-routed. - : base(-1, flags.WithCategory(CommandFlags.CommandRetryConnection), RedisCommand.SCRIPT) + : base(-1, flags.WithRetryCategory(CommandFlags.CommandRetryConnection), RedisCommand.SCRIPT) { Script = script ?? throw new ArgumentNullException(nameof(script)); } diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 17767a73d..419a39635 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -74,13 +74,13 @@ public RedisKey InventKey(RedisKey prefix = default) public void ClientKill(EndPoint endpoint, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.CLIENT, RedisLiterals.KILL, Format.ToString(endpoint).AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.CLIENT, RedisLiterals.KILL, Format.ToString(endpoint).AsRedisValue()); ExecuteSync(msg, ResultProcessor.DemandOK); } public Task ClientKillAsync(EndPoint endpoint, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.CLIENT, RedisLiterals.KILL, Format.ToString(endpoint).AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.CLIENT, RedisLiterals.KILL, Format.ToString(endpoint).AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.DemandOK); } @@ -98,20 +98,20 @@ public Task ClientKillAsync(long? id = null, ClientType? clientType = null public long ClientKill(ClientKillFilter filter, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); return ExecuteSync(msg, ResultProcessor.Int64); } public Task ClientKillAsync(ClientKillFilter filter, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); return ExecuteAsync(msg, ResultProcessor.Int64); } private Message GetClientKillMessage(EndPoint? endpoint, long? id, ClientType? clientType, bool? skipMe, CommandFlags flags) { var args = new ClientKillFilter().WithId(id).WithClientType(clientType).WithEndpoint(endpoint).WithSkipMe(skipMe).ToList(Features.ReplicaCommands); - return Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.CLIENT, args); + return Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.CLIENT, args); } public ClientInfo[] ClientList(CommandFlags flags = CommandFlags.None) @@ -160,7 +160,7 @@ public Task ClientListAsync(CommandFlags flags = CommandFlags.None /// cannot drift between the three places we ask the same question. /// internal static Message GetClusterNodesMessage(CommandFlags flags) - => Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.CLUSTER, RedisLiterals.NODES); + => Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.CLUSTER, RedisLiterals.NODES); /// /// As , for the CLUSTER SLOTS view of the same topology: @@ -168,7 +168,7 @@ internal static Message GetClusterNodesMessage(CommandFlags flags) /// read - it reports what the answering node believes, so it is safe to replay against that node. /// internal static Message GetClusterSlotsMessage(CommandFlags flags) - => Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.CLUSTER, RedisLiterals.SLOTS); + => Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.CLUSTER, RedisLiterals.SLOTS); public KeyValuePair[] ConfigGet(RedisValue pattern = default, CommandFlags flags = CommandFlags.None) { @@ -187,7 +187,7 @@ internal static Message GetConfigGetMessage(RedisValue pattern, CommandFlags fla if (pattern.IsNullOrEmpty) pattern = RedisLiterals.Wildcard; // CONFIG as a whole is server-admin, but CONFIG GET is safe metadata - return Message.Create(-1, flags.WithCategory(CommandFlags.CommandRetryConnection | Message.CommandServerSpecific), RedisCommand.CONFIG, RedisLiterals.GET, pattern); + return Message.Create(-1, flags.WithRetryCategory(CommandFlags.CommandRetryConnection | Message.CommandServerSpecific), RedisCommand.CONFIG, RedisLiterals.GET, pattern); } public void ConfigResetStatistics(CommandFlags flags = CommandFlags.None) @@ -465,25 +465,25 @@ public Task SaveAsync(SaveType type, CommandFlags flags = CommandFlags.None) public bool ScriptExists(string script, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Hash(script)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Hash(script)); return ExecuteSync(msg, ResultProcessor.Boolean); } public bool ScriptExists(byte[] sha1, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Encode(sha1)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Encode(sha1)); return ExecuteSync(msg, ResultProcessor.Boolean); } public Task ScriptExistsAsync(string script, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Hash(script)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Hash(script)); return ExecuteAsync(msg, ResultProcessor.Boolean); } public Task ScriptExistsAsync(byte[] sha1, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Encode(sha1)); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SCRIPT, RedisLiterals.EXISTS, ScriptHash.Encode(sha1)); return ExecuteAsync(msg, ResultProcessor.Boolean); } @@ -558,8 +558,8 @@ public Task SlowlogGetAsync(int count = 0, CommandFlags flags = } internal static Message GetSlowlogGetMessage(int count, CommandFlags flags) => count > 0 - ? Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SLOWLOG, RedisLiterals.GET, count) - : Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SLOWLOG, RedisLiterals.GET); + ? Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SLOWLOG, RedisLiterals.GET, count) + : Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SLOWLOG, RedisLiterals.GET); public void SlowlogReset(CommandFlags flags = CommandFlags.None) { @@ -980,49 +980,49 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes public EndPoint? SentinelGetMasterAddressByName(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.GETMASTERADDRBYNAME, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.GETMASTERADDRBYNAME, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.SentinelPrimaryEndpoint); } public Task SentinelGetMasterAddressByNameAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.GETMASTERADDRBYNAME, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.GETMASTERADDRBYNAME, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.SentinelPrimaryEndpoint); } public EndPoint[] SentinelGetSentinelAddresses(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.SentinelAddressesEndPoints, defaultValue: Array.Empty()); } public Task SentinelGetSentinelAddressesAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.SentinelAddressesEndPoints, defaultValue: Array.Empty()); } public EndPoint[] SentinelGetReplicaAddresses(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.SentinelAddressesEndPoints, defaultValue: Array.Empty()); } public Task SentinelGetReplicaAddressesAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.SentinelAddressesEndPoints, defaultValue: Array.Empty()); } public KeyValuePair[] SentinelMaster(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTER, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTER, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.StringPairInterleaved, defaultValue: Array.Empty>()); } public Task[]> SentinelMasterAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTER, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTER, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.StringPairInterleaved, defaultValue: Array.Empty>()); } @@ -1040,13 +1040,13 @@ public Task SentinelFailoverAsync(string serviceName, CommandFlags flags = Comma public KeyValuePair[][] SentinelMasters(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTERS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTERS); return ExecuteSync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } public Task[][]> SentinelMastersAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTERS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.MASTERS); return ExecuteAsync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } @@ -1056,7 +1056,7 @@ KeyValuePair[][] IServer.SentinelSlaves(string serviceName, Comm public KeyValuePair[][] SentinelReplicas(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } @@ -1066,19 +1066,19 @@ Task[][]> IServer.SentinelSlavesAsync(string servic public Task[][]> SentinelReplicasAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, Features.ReplicaCommands ? RedisLiterals.REPLICAS : RedisLiterals.SLAVES, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } public KeyValuePair[][] SentinelSentinels(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); return ExecuteSync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } public Task[][]> SentinelSentinelsAsync(string serviceName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.SENTINEL, RedisLiterals.SENTINELS, serviceName.AsRedisValue()); return ExecuteAsync(msg, ResultProcessor.SentinelArrayOfArrays, defaultValue: Array.Empty[]>()); } @@ -1124,13 +1124,13 @@ public Task ExecuteAsync(int? database, string command, ICollection public Task LatencyDoctorAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.DOCTOR); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.DOCTOR); return ExecuteAsync(msg, ResultProcessor.String!, defaultValue: string.Empty); } public string LatencyDoctor(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.DOCTOR); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.DOCTOR); return ExecuteSync(msg, ResultProcessor.String, defaultValue: string.Empty); } @@ -1165,37 +1165,37 @@ public long LatencyReset(string[]? eventNames = null, CommandFlags flags = Comma public Task LatencyHistoryAsync(string eventName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.HISTORY, eventName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.HISTORY, eventName.AsRedisValue()); return ExecuteAsync(msg, LatencyHistoryEntry.ToArray, defaultValue: Array.Empty()); } public LatencyHistoryEntry[] LatencyHistory(string eventName, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.HISTORY, eventName.AsRedisValue()); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.HISTORY, eventName.AsRedisValue()); return ExecuteSync(msg, LatencyHistoryEntry.ToArray, defaultValue: Array.Empty()); } public Task LatencyLatestAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.LATEST); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.LATEST); return ExecuteAsync(msg, LatencyLatestEntry.ToArray, defaultValue: Array.Empty()); } public LatencyLatestEntry[] LatencyLatest(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.LATEST); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.LATENCY, RedisLiterals.LATEST); return ExecuteSync(msg, LatencyLatestEntry.ToArray, defaultValue: Array.Empty()); } public Task MemoryDoctorAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.DOCTOR); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.DOCTOR); return ExecuteAsync(msg, ResultProcessor.String!, defaultValue: string.Empty); } public string MemoryDoctor(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.DOCTOR); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.DOCTOR); return ExecuteSync(msg, ResultProcessor.String, defaultValue: string.Empty); } @@ -1212,29 +1212,29 @@ public void MemoryPurge(CommandFlags flags = CommandFlags.None) } internal static Message GetMemoryPurgeMessage(CommandFlags flags) - => Message.Create(-1, flags.WithCategory(NodeLocalAdmin), RedisCommand.MEMORY, RedisLiterals.PURGE); + => Message.Create(-1, flags.WithRetryCategory(NodeLocalAdmin), RedisCommand.MEMORY, RedisLiterals.PURGE); public Task MemoryAllocatorStatsAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.MALLOC_STATS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.MALLOC_STATS); return ExecuteAsync(msg, ResultProcessor.String); } public string? MemoryAllocatorStats(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.MALLOC_STATS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.MALLOC_STATS); return ExecuteSync(msg, ResultProcessor.String); } public Task MemoryStatsAsync(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.STATS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.STATS); return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullArray); } public RedisResult MemoryStats(CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.STATS); + var msg = Message.Create(-1, flags.WithRetryCategory(NodeLocalRead), RedisCommand.MEMORY, RedisLiterals.STATS); return ExecuteSync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullArray); } } diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index e2846c687..a111ffd86 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -633,7 +633,7 @@ internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? if (commandMap.IsAvailable(RedisCommand.SENTINEL)) { // SENTINEL MASTERS only reads the sentinel's view, despite SENTINEL defaulting to server-admin - msg = Message.Create(-1, flags.WithCategory(CommandFlags.CommandRetryReadOnly | Message.CommandServerSpecific), RedisCommand.SENTINEL, RedisLiterals.MASTERS); + msg = Message.Create(-1, flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly | Message.CommandServerSpecific), RedisCommand.SENTINEL, RedisLiterals.MASTERS); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfigProcessor).ForAwait(); } diff --git a/src/StackExchange.Redis/SortedSetAddMessage.cs b/src/StackExchange.Redis/SortedSetAddMessage.cs index 87dd1df69..e8d080356 100644 --- a/src/StackExchange.Redis/SortedSetAddMessage.cs +++ b/src/StackExchange.Redis/SortedSetAddMessage.cs @@ -10,7 +10,7 @@ private abstract class SortedSetAddMessage( in RedisKey key, SortedSetWhen when, bool change, - bool increment) : Message.CommandKeyBase(db, flags.WithCategory(GetRetryCategory(when, increment)), RedisCommand.ZADD, key) + bool increment) : Message.CommandKeyBase(db, flags.WithRetryCategory(GetRetryCategory(when, increment)), RedisCommand.ZADD, key) { private const SortedSetWhen KnownWhen = SortedSetWhen.Exists | SortedSetWhen.GreaterThan | SortedSetWhen.LessThan | SortedSetWhen.NotExists; diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 8c621a7a0..ff3dc6631 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -337,7 +337,7 @@ public void SendRunsOnceThenServesFromCache() { // note: no 'using' on the frame and none on any payload - Send owns both var frame = Get("abc"); - Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); + Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); } Assert.Equal(1, executor.Sent); @@ -350,10 +350,10 @@ public async Task SendAsyncMatchesSyncAndHitsCompleteSynchronously() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - Assert.Equal("$5|hello|", await Via(executor, cache).SendAsync(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); + Assert.Equal("$5|hello|", await Via(executor, cache).SendAsync(ref miss, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); var hit = Get("abc"); - var pending = Via(executor, cache).SendAsync(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + var pending = Via(executor, cache).SendAsync(ref hit, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); // a hit never touches the executor, so it must not build a state machine or a Task either Assert.True(pending.IsCompletedSuccessfully); @@ -368,7 +368,7 @@ public void ExecutorCanRetainTheRequestForAResend() var executor = new FakeExecutor("$5\r\nhello\r\n") { ParkRequests = true }; var frame = Get("abc"); - Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); // this is why the request is not a span: a backlog must be able to hold it past the call, and // still read it afterwards to resend @@ -384,7 +384,7 @@ public void CachedReplyIsSharedWithTheCallerNotCopied() var executor = new FakeExecutor("$5\r\nhello\r\n"); var frame = Get("abc"); - Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); using var probe = Get("abc"); Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); @@ -406,11 +406,11 @@ public void SendWithoutACacheIsTheSameCallShape() var executor = new FakeExecutor("$5\r\nhello\r\n"); var a = Get("abc"); - Assert.Equal("$5|hello|", Via(executor).Send(ref a, TextHandler.Instance, CommandFlags.None)); + Assert.Equal("$5|hello|", Via(executor).Send(ref a, CommandFlags.None, TextHandler.Instance)); // a null cache takes the same overload, so enabling caching is one argument, not a rewrite var b = Get("abc"); - Assert.Equal("$5|hello|", Via(executor).Send(ref b, TextHandler.Instance, CommandFlags.None)); + Assert.Equal("$5|hello|", Via(executor).Send(ref b, CommandFlags.None, TextHandler.Instance)); Assert.Equal(2, executor.Sent); // no caching either way } @@ -425,7 +425,7 @@ public void SendStillAnswersWhenInvalidatedInFlight() var executor = new FakeExecutor("$5\r\nhello\r\n", () => cache.OnInvalidate(Utf8("abc"))); var frame = Get("abc"); - Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); // still answered + Assert.Equal("$5|hello|", Via(executor, cache).Send(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); // still answered Assert.Equal(0, cache.Count); // ... not cached } @@ -438,7 +438,7 @@ public void SendAnswersEvenWhenTheFrameCannotBeCached() var frame = writer.Complete(); var executor = new FakeExecutor("$2\r\nok\r\n"); - Assert.Equal("$2|ok|", Via(executor, cache).Send(ref frame, TextHandler.Instance, CommandFlags.CommandRetryReadOnly)); + Assert.Equal("$2|ok|", Via(executor, cache).Send(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); Assert.Equal(0, cache.Count); // this path FALLS THROUGH to the uncached tail rather than duplicating it, so the frame must be @@ -453,10 +453,10 @@ public void SendLeavesNoReferenceBehindOnAnyPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - Via(executor, cache).Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref fill, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); var hit = Get("abc"); - Via(executor, cache).Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref hit, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); // exactly one reference survives - the cache entry's. If the helper leaked the caller's retain the // buffer would never return to the pool; if it over-released, the entry would be reading freed bytes @@ -474,15 +474,15 @@ public void SendConsumesTheFrameOnEveryPath() var executor = new FakeExecutor("$5\r\nhello\r\n"); var miss = Get("abc"); - Via(executor, cache).Send(ref miss, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref miss, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); Assert.Throws(() => miss.AsLookupKey()); var hit = Get("abc"); - Via(executor, cache).Send(ref hit, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref hit, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); Assert.Throws(() => hit.AsLookupKey()); var uncached = Get("abc"); - Via(executor).Send(ref uncached, TextHandler.Instance, CommandFlags.None); // the no-cache overload too + Via(executor).Send(ref uncached, CommandFlags.None, TextHandler.Instance); // the no-cache overload too Assert.Throws(() => uncached.AsLookupKey()); } @@ -566,19 +566,18 @@ public void NoClientCacheAlsoSuppressesServingFromCache() var executor = new FakeExecutor("$5\r\nhello\r\n"); var fill = Get("abc"); - Via(executor, cache).Send(ref fill, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref fill, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); Assert.Equal(1, executor.Sent); // opting out must mean the caller does not RECEIVE a cached answer either - not merely that this // reply is not kept. Otherwise "don't cache this" silently still serves stale data. var opted = Get("abc"); - Via(executor, cache).Send( - ref opted, TextHandler.Instance, CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache); + Via(executor, cache).Send(ref opted, CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache, TextHandler.Instance); Assert.Equal(2, executor.Sent); // ... and the entry is untouched for callers who did not opt out var normal = Get("abc"); - Via(executor, cache).Send(ref normal, TextHandler.Instance, CommandFlags.CommandRetryReadOnly); + Via(executor, cache).Send(ref normal, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); Assert.Equal(2, executor.Sent); } diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index e325c14dc..b899d8b86 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -23,9 +23,13 @@ private sealed class FakeExecutor(params string[] replies) : IRespExecutor public int Database => 0; + /// The flags each request carried, so the tests can assert what reached the wire. + public List Flags { get; } = []; + public RespPayload Send(in RespRequest request) { Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + Flags.Add(request.Flags); return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); } @@ -81,6 +85,54 @@ public void ChannelPrefixSurvivesUnrelatedClones() Assert.Equal(4, ctx.Database); } + [Fact] + public async Task FlagsAreCumulativeRatherThanReplacing() + { + var executor = new FakeExecutor("+OK\r\n"); + var target = Target(executor); + + // FireAndForget must not cost the command its retry category. It would have, when the category + // lived in the parameter's DEFAULT value - passing any flag replaced it with nothing. + await target.Strings.Set("k", "v", CommandFlags.FireAndForget); + + var sent = Assert.Single(executor.Flags); + Assert.Equal(CommandFlags.FireAndForget, sent & CommandFlags.FireAndForget); + Assert.Equal(CommandFlags.CommandRetryWriteLastWins, sent & Message.MaskRetryCategory); + } + + [Fact] + public async Task AnExplicitCategoryStillWins() + { + var executor = new FakeExecutor("+OK\r\n"); + var target = Target(executor); + + // WithRetryCategory is first-wins, so a caller who names one keeps it + await target.Strings.Set("k", "v", CommandFlags.CommandRetryNever); + Assert.Equal(CommandFlags.CommandRetryNever, Assert.Single(executor.Flags) & Message.MaskRetryCategory); + } + + [Fact] + public async Task TheHandlerCanBeOmitted() + { + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var ctx = new RespContext().WithExecutor(executor); + + // no handler named: resolved from TResult, which is what lets a command surface be one expression + Assert.Equal("hello", await ctx.SendAsync( + $"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly)); + } + + [Fact] + public async Task AnUnregisteredResultTypeSaysSoAtTheCallSite() + { + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var ctx = new RespContext().WithExecutor(executor); + + var ex = await Assert.ThrowsAsync(async () => + await ctx.SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly)); + Assert.Contains("Uri", ex.Message); + } + [Fact] public async Task SetAndGetThroughTheGroupedSurface() { From 502146d013f1b25b6cd0f8f759895b3f23d2a2a0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 12:40:31 +0100 Subject: [PATCH 077/360] cmd.Append($"..."): conditional fragments written like the command 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. --- design/interpolated-resp-writer.md | 26 +++++ .../Interpolated/RespAppendHandler.cs | 106 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 11 ++ .../InterpolatedAppendTests.cs | 77 +++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespAppendHandler.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index cc757c808..baf9aa7d2 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -657,6 +657,32 @@ works, or callers are forced into `try`/`finally`. ### 4.1 `Compose` / `Execute(ref cmd)` — the shape for optional arguments +> **`cmd.Append($"…")`.** A conditional fragment is now written the same way as the command itself: +> ```csharp +> var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); +> if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); +> using var frame = ctx.Execute(ref cmd); +> ``` +> rather than a sequence of `AppendFormatted` calls whose order is the caller's to keep straight. +> +> **It moves the command rather than proxying to it.** The obvious design — a handler holding +> `ref RespCommandHandler` and forwarding each call — does not compile on **any** target: *CS9050, a ref +> field cannot refer to a ref struct*. That is a language rule, not a down-level runtime gap, so narrowing +> the target frameworks would not have helped. (netfx adds CS9064 on top, but it is not the blocker.) +> +> So 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 the +> moment the window closes — **including when a growth inside the window swapped the array**, which is the +> case that distinguishes a move from a share, and has its own test. +> +> Two things make it legal, both worth knowing because the errors are opaque: +> `Append` is an **extension** with an explicit `ref` parameter rather than an instance method, because as +> an instance method the compiler must pass `ref this` into the handler's 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. + + + Implemented in the spike (§9): ```csharp diff --git a/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs b/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs new file mode 100644 index 000000000..d8e5ff80e --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs @@ -0,0 +1,106 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Appends to a command already being built, so a conditional fragment is written + /// the same way as the command itself. + /// + /// + /// + /// var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); + /// if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); + /// using var frame = ctx.Execute(ref cmd); + /// + /// + /// It moves rather than proxies. The obvious design - hold ref RespCommandHandler and + /// forward each call - does not compile on any target: CS9050, a ref field cannot refer to a + /// ref struct. That is a language rule, not a down-level runtime gap, so targeting only modern + /// frameworks would not have helped. + /// + /// + /// So the command is copied in, appended to, and assigned back. Both structs reference the same pooled + /// array during that window, but only the copy is ever touched, and the original is overwritten by + /// Append the moment the window closes - including when a growth + /// inside the window swapped the array. Move semantics, not sharing; nothing is copied but the struct + /// itself, and no second buffer is rented. + /// + /// + /// EXPERIMENTAL SPIKE. Appending to a command already being built. + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespAppend + { + /// + /// Append more arguments, written the same way as the command itself. + /// + /// The command being built. + /// The fragment; supplied by the compiler from an interpolated string. + /// + /// An extension rather than an instance method on : as an instance + /// method the compiler must pass ref this into the handler's constructor, and then refuses + /// the call outright (CS8350/CS8352), because it cannot see that the reference does not escape. As + /// an explicit scoped ref parameter it can. The call site is identical either way. + /// + public static void Append( + this ref RespCommandHandler command, + [InterpolatedStringHandlerArgument(nameof(command))] ref RespAppendHandler handler) + => command = handler.Take(); + } + + /// + /// EXPERIMENTAL SPIKE. Collects the arguments of an Append onto the command being built. + /// + /// See for the intended usage; this type is the compiler's. + [InterpolatedStringHandler] + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public ref struct RespAppendHandler + { + private RespCommandHandler _inner; + + /// Begin appending to a command. + /// Total length of the literal segments; compiler-supplied. + /// Number of holes; compiler-supplied. + /// The command being built; moved in, and moved back out by Append. + /// + /// scoped is what makes this legal: without it the compiler must assume the constructor + /// might store the reference, and refuses the call (CS8350/CS8352). It cannot - the command is + /// copied by value - and scoped is how that is said. + /// + public RespAppendHandler(int literalLength, int formattedCount, scoped ref RespCommandHandler target) + { + _ = literalLength; + _ = formattedCount; + _inner = target; + } + + /// + /// The literal text. + public void AppendLiteral(string value) => _inner.AppendLiteral(value); + + /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. + /// The key. + public void AppendFormatted(RedisKey value) => _inner.AppendFormatted(value); + + /// Append a value. + /// The value. + public void AppendFormatted(RedisValue value) => _inner.AppendFormatted(value); + + /// Append a channel. + /// The channel. + public void AppendFormatted(RedisChannel value) => _inner.AppendFormatted(value); + + /// Append a pre-framed fragment. + /// The fragment. + public void AppendFormatted(RespFragment value) => _inner.AppendFormatted(value); + + /// Append a resolved command name. + /// The command. + public void AppendFormatted(RespCommand value) => _inner.AppendFormatted(value); + + /// Hand the command back, with everything appended. + internal RespCommandHandler Take() => _inner; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 0e88c0242..44c37b12e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -10,6 +10,16 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.KeyRange.KeyRange(int offset, int length) -> void [SER010]StackExchange.Redis.Interpolated.KeyRange.Length.get -> int [SER010]StackExchange.Redis.Interpolated.KeyRange.Offset.get -> int +[SER010]StackExchange.Redis.Interpolated.RespAppend +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendLiteral(string! value) -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.RespAppendHandler() -> void +[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.RespAppendHandler(int literalLength, int formattedCount, scoped ref StackExchange.Redis.Interpolated.RespCommandHandler target) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void @@ -144,6 +154,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! +[SER010]static StackExchange.Redis.Interpolated.RespAppend.Append(this ref StackExchange.Redis.Interpolated.RespCommandHandler command, ref StackExchange.Redis.Interpolated.RespAppendHandler handler) -> void [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this System.ReadOnlySpan name) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this string! name, bool preform = false) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> TResult diff --git a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs new file mode 100644 index 000000000..c1209131b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// cmd.Append($"..."): a conditional fragment written the same way as the command. +/// +public partial class InterpolatedAppendTests +{ + private static readonly RespContext Ctx = new(); + + private static string Text(in RespFrame frame) => + Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + internal static partial class RespLiterals + { +#pragma warning disable SER011 // stands in for the generator + internal static RespFragment EX => new("$2\r\nEX\r\n"u8); +#pragma warning restore SER011 + } + + [Theory] + [InlineData(false, "*3|$3|SET|$1|k|$1|v|")] + [InlineData(true, "*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|")] + public void ConditionalAppendMatchesTheUnconditionalForm(bool withTtl, string expected) + { + var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + if (withTtl) cmd.Append($"{RespLiterals.EX}{(RedisValue)300}"); + + using var frame = Ctx.Execute(ref cmd); + Assert.Equal(expected, Text(frame)); + } + + [Fact] + public void AppendSurvivesABufferGrowth() + { + // the moved-in copy is what grows, swapping to a new pooled array - so if Append did not assign the + // copy back, the command would still point at the OLD array, which Ensure has already returned to + // the pool. This is the case that proves it is a move rather than a share. + var big = new string('x', 4096); + var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}"); + cmd.Append($"{(RedisValue)big}{(RedisValue)big}"); + + using var frame = Ctx.Execute(ref cmd); + var text = Text(frame); + Assert.StartsWith("*4|$3|SET|$1|k|$4096|", text); + Assert.Equal(4, frame.ArgCount); + } + + [Fact] + public void SeveralAppendsAccumulate() + { + var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + cmd.Append($"{RespLiterals.EX}{(RedisValue)300}"); + cmd.Append($"{(RedisValue)"XX"}"); + + using var frame = Ctx.Execute(ref cmd); + Assert.Equal("*6|$3|SET|$1|k|$1|v|$2|EX|$3|300|$2|XX|", Text(frame)); + } + + [Fact] + public void KeysAppendedThisWayAreStillMarked() + { + var cmd = Ctx.Compose($"{RedisCommand.MGET}{(RedisKey)"a"}"); + cmd.Append($"{(RedisKey)"b"}"); + + using var frame = Ctx.Execute(ref cmd); + Assert.Equal(2, frame.KeyCount); + var ranges = new KeyRange[2]; + Assert.Equal(2, frame.TryGetKeys(ranges)); + Assert.Equal("a", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + Assert.Equal("b", Encoding.UTF8.GetString(frame.GetKey(ranges[1]).ToArray())); + } +} From 6ba2820db53ebef65cabbda685db935acd0da8fc Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 13:05:43 +0100 Subject: [PATCH 078/360] One handler type: the append handler IS the command handler 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. --- design/interpolated-resp-writer.md | 10 +- .../Interpolated/RespAppend.cs | 42 +++++++ .../Interpolated/RespAppendHandler.cs | 106 ------------------ .../Interpolated/RespCommandHandler.cs | 27 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 12 +- .../InterpolatedAppendTests.cs | 19 ++++ 6 files changed, 99 insertions(+), 117 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespAppend.cs delete mode 100644 src/StackExchange.Redis/Interpolated/RespAppendHandler.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index baf9aa7d2..95f008d66 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -665,7 +665,7 @@ works, or callers are forced into `try`/`finally`. > ``` > rather than a sequence of `AppendFormatted` calls whose order is the caller's to keep straight. > -> **It moves the command rather than proxying to it.** The obvious design — a handler holding +> **It moves the command rather than referring to it.** The obvious design — a handler holding > `ref RespCommandHandler` and forwarding each call — does not compile on **any** target: *CS9050, a ref > field cannot refer to a ref struct*. That is a language rule, not a down-level runtime gap, so narrowing > the target frameworks would not have helped. (netfx adds CS9064 on top, but it is not the blocker.) @@ -675,6 +675,14 @@ works, or callers are forced into `try`/`finally`. > moment the window closes — **including when a growth inside the window swapped the array**, which is the > case that distinguishes a move from a share, and has its own test. > +> **There is only one handler type**, which is what makes this safe rather than merely neat. A separate +> proxy type would need its `AppendFormatted` overloads kept in step with the command handler's - and the +> failure would be quiet, since adding one there without adding it here just makes `cmd.Append($"{x}")` +> stop compiling, with nothing to say why it works in the command and not in the append. The handler for +> an append simply **is** the command handler, so an append accepts exactly what the command does by +> construction. (That guard was written, as a reflection test, before the single-type version replaced the +> need for it.) +> > Two things make it legal, both worth knowing because the errors are opaque: > `Append` is an **extension** with an explicit `ref` parameter rather than an instance method, because as > an instance method the compiler must pass `ref this` into the handler's constructor and then refuses the diff --git a/src/StackExchange.Redis/Interpolated/RespAppend.cs b/src/StackExchange.Redis/Interpolated/RespAppend.cs new file mode 100644 index 000000000..d54e17d02 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespAppend.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Appending to a command already being built, so a conditional fragment is + /// written the same way as the command itself. + /// + /// + /// + /// var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); + /// if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); + /// using var frame = ctx.Execute(ref cmd); + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespAppend + { + /// Append more arguments, written the same way as the command itself. + /// The command being built. + /// The fragment; supplied by the compiler from an interpolated string. + /// + /// + /// The handler here is a - the same type, moved in and moved back + /// out - so an append accepts exactly what the command does, by construction rather than by + /// keeping two lists aligned. + /// + /// + /// An extension with an explicit ref parameter, not an instance method: as an instance + /// method the compiler must pass ref this into the handler's constructor and then refuses + /// the call (CS8350/CS8352), because it cannot see that the reference does not escape. The + /// scoped on that constructor is how it is told. The call site is identical either way. + /// + /// + public static void Append( + this ref RespCommandHandler command, + [InterpolatedStringHandlerArgument(nameof(command))] ref RespCommandHandler handler) + => command = handler; + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs b/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs deleted file mode 100644 index d8e5ff80e..000000000 --- a/src/StackExchange.Redis/Interpolated/RespAppendHandler.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using RESPite; - -namespace StackExchange.Redis.Interpolated -{ - /// - /// EXPERIMENTAL SPIKE. Appends to a command already being built, so a conditional fragment is written - /// the same way as the command itself. - /// - /// - /// - /// var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); - /// if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); - /// using var frame = ctx.Execute(ref cmd); - /// - /// - /// It moves rather than proxies. The obvious design - hold ref RespCommandHandler and - /// forward each call - does not compile on any target: CS9050, a ref field cannot refer to a - /// ref struct. That is a language rule, not a down-level runtime gap, so targeting only modern - /// frameworks would not have helped. - /// - /// - /// So the command is copied in, appended to, and assigned back. Both structs reference the same pooled - /// array during that window, but only the copy is ever touched, and the original is overwritten by - /// Append the moment the window closes - including when a growth - /// inside the window swapped the array. Move semantics, not sharing; nothing is copied but the struct - /// itself, and no second buffer is rented. - /// - /// - /// EXPERIMENTAL SPIKE. Appending to a command already being built. - [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public static class RespAppend - { - /// - /// Append more arguments, written the same way as the command itself. - /// - /// The command being built. - /// The fragment; supplied by the compiler from an interpolated string. - /// - /// An extension rather than an instance method on : as an instance - /// method the compiler must pass ref this into the handler's constructor, and then refuses - /// the call outright (CS8350/CS8352), because it cannot see that the reference does not escape. As - /// an explicit scoped ref parameter it can. The call site is identical either way. - /// - public static void Append( - this ref RespCommandHandler command, - [InterpolatedStringHandlerArgument(nameof(command))] ref RespAppendHandler handler) - => command = handler.Take(); - } - - /// - /// EXPERIMENTAL SPIKE. Collects the arguments of an Append onto the command being built. - /// - /// See for the intended usage; this type is the compiler's. - [InterpolatedStringHandler] - [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public ref struct RespAppendHandler - { - private RespCommandHandler _inner; - - /// Begin appending to a command. - /// Total length of the literal segments; compiler-supplied. - /// Number of holes; compiler-supplied. - /// The command being built; moved in, and moved back out by Append. - /// - /// scoped is what makes this legal: without it the compiler must assume the constructor - /// might store the reference, and refuses the call (CS8350/CS8352). It cannot - the command is - /// copied by value - and scoped is how that is said. - /// - public RespAppendHandler(int literalLength, int formattedCount, scoped ref RespCommandHandler target) - { - _ = literalLength; - _ = formattedCount; - _inner = target; - } - - /// - /// The literal text. - public void AppendLiteral(string value) => _inner.AppendLiteral(value); - - /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. - /// The key. - public void AppendFormatted(RedisKey value) => _inner.AppendFormatted(value); - - /// Append a value. - /// The value. - public void AppendFormatted(RedisValue value) => _inner.AppendFormatted(value); - - /// Append a channel. - /// The channel. - public void AppendFormatted(RedisChannel value) => _inner.AppendFormatted(value); - - /// Append a pre-framed fragment. - /// The fragment. - public void AppendFormatted(RespFragment value) => _inner.AppendFormatted(value); - - /// Append a resolved command name. - /// The command. - public void AppendFormatted(RespCommand value) => _inner.AppendFormatted(value); - - /// Hand the command back, with everything appended. - internal RespCommandHandler Take() => _inner; - } -} diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 729e1d1cb..b578934a9 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -298,6 +298,33 @@ public void AppendFormatted(RespCommand value) _argIndex++; } + /// + /// Continue an existing command, for cmd.Append($"..."). + /// + /// Total length of the literal segments; compiler-supplied. + /// Number of holes; compiler-supplied. + /// The command being built; moved in, and moved back out by Append. + /// + /// + /// The handler for an append is the command handler, so there is exactly one set of + /// AppendFormatted overloads and no way for a proxy to fall out of step with it. A separate + /// proxy type was the obvious design and it could not even hold a reference to its target: + /// CS9050, a ref field cannot refer to a ref struct, on every target. + /// + /// + /// It moves rather than shares. The command is copied in here, appended to, and assigned + /// back by Append. Both copies reference the same pooled array in between, but only this one + /// is touched, and the original is overwritten as the window closes - including when a growth + /// inside the window swapped the array, which is what separates a move from a share. + /// + /// + public RespCommandHandler(int literalLength, int formattedCount, scoped ref RespCommandHandler command) + { + _ = literalLength; + _ = formattedCount; + this = command; + } + /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. /// The key to append. public void AppendFormatted(RedisKey value) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 44c37b12e..e3c1a0be9 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -11,15 +11,6 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.KeyRange.Length.get -> int [SER010]StackExchange.Redis.Interpolated.KeyRange.Offset.get -> int [SER010]StackExchange.Redis.Interpolated.RespAppend -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.AppendLiteral(string! value) -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.RespAppendHandler() -> void -[SER010]StackExchange.Redis.Interpolated.RespAppendHandler.RespAppendHandler(int literalLength, int formattedCount, scoped ref StackExchange.Redis.Interpolated.RespCommandHandler target) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute() -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void @@ -63,6 +54,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, scoped ref StackExchange.Redis.Interpolated.RespCommandHandler command) -> void [SER010]StackExchange.Redis.Interpolated.RespCommands [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.Cache.get -> StackExchange.Redis.Interpolated.RespClientCache? @@ -154,7 +146,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! -[SER010]static StackExchange.Redis.Interpolated.RespAppend.Append(this ref StackExchange.Redis.Interpolated.RespCommandHandler command, ref StackExchange.Redis.Interpolated.RespAppendHandler handler) -> void +[SER010]static StackExchange.Redis.Interpolated.RespAppend.Append(this ref StackExchange.Redis.Interpolated.RespCommandHandler command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this System.ReadOnlySpan name) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this string! name, bool preform = false) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> TResult diff --git a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs index c1209131b..aba38c88d 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Reflection; using System.Text; using StackExchange.Redis.Interpolated; using Xunit; @@ -22,6 +24,23 @@ internal static partial class RespLiterals #pragma warning restore SER011 } + [Fact] + public void AnAppendAcceptsExactlyWhatTheCommandDoes() + { + // by construction, not by keeping two lists aligned: the handler for an append IS the command + // handler, so there is one set of overloads and nothing to fall out of step. An earlier design + // used a separate proxy type and needed a test to guard exactly this. + var accepted = typeof(RespCommandHandler) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.Name == "AppendFormatted") + .Select(m => m.GetParameters().Single().ParameterType.Name) + .ToArray(); + + Assert.Contains("RedisKey", accepted); + Assert.Contains("RedisValue", accepted); + Assert.Contains("RespFragment", accepted); + } + [Theory] [InlineData(false, "*3|$3|SET|$1|k|$1|v|")] [InlineData(true, "*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|")] From f184c87c2d191bfff5aa6bf28bac20bc32e0763b Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 13:10:56 +0100 Subject: [PATCH 079/360] Append handler ctor takes `scoped in`, not `scoped ref` The ctor only reads the source command (`this = command`), so `in` is the honest signature; `ref` advertised a mutation that never happens. Compiles on every target (net461 through net10.0) - the only fallout was the PublicAPI signature line. --- src/StackExchange.Redis/Interpolated/RespCommandHandler.cs | 2 +- src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index b578934a9..b2e97fe93 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -318,7 +318,7 @@ public void AppendFormatted(RespCommand value) /// inside the window swapped the array, which is what separates a move from a share. /// /// - public RespCommandHandler(int literalLength, int formattedCount, scoped ref RespCommandHandler command) + public RespCommandHandler(int literalLength, int formattedCount, scoped in RespCommandHandler command) { _ = literalLength; _ = formattedCount; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e3c1a0be9..deb54b2eb 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -54,7 +54,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, scoped ref StackExchange.Redis.Interpolated.RespCommandHandler command) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, scoped in StackExchange.Redis.Interpolated.RespCommandHandler command) -> void [SER010]StackExchange.Redis.Interpolated.RespCommands [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.Cache.get -> StackExchange.Redis.Interpolated.RespClientCache? From 50eaf79824c83e3f5d9bd07a4b7f52919aeab7df Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 13:16:04 +0100 Subject: [PATCH 080/360] Complete the append move at both ends: reset the source The append handler IS the command handler, moved in and moved back out - but only the value was moving. Both copies stayed live, so the command spent the append window as a second owner of the pooled array, pointing at memory a growth inside that window may already have returned. Now the ctor resets the source after copying it, and Append resets the handler after assigning it back: exactly one copy owns the buffer at any instant. `default` rather than just clearing the buffer, because that clears _hasCommand with it - so every path off a moved-from handler is a clean throw (Complete, AppendFormatted) or a no-op (Dispose, no double return to the pool), never an NRE. Same idiom Complete already used. That reset is why the ctor parameter goes back to `ref`: `in` was right while it only read. Both resets are mutation-tested. --- design/interpolated-resp-writer.md | 22 ++++++++-- .../Interpolated/RespAppend.cs | 13 +++++- .../Interpolated/RespCommandHandler.cs | 22 +++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- .../InterpolatedAppendTests.cs | 41 ++++++++++++++++++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 95f008d66..cab3383b6 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -670,10 +670,24 @@ works, or callers are forced into `try`/`finally`. > field cannot refer to a ref struct*. That is a language rule, not a down-level runtime gap, so narrowing > the target frameworks would not have helped. (netfx adds CS9064 on top, but it is not the blocker.) > -> So 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 the -> moment the window closes — **including when a growth inside the window swapped the array**, which is the -> case that distinguishes a move from a share, and has its own test. +> So the command is copied into the handler, appended to, and assigned back — **including when a growth +> inside the window swapped the array**, which is the case that distinguishes a move from a share, and has +> its own test. +> +> **The move is completed at both ends.** The constructor resets the source to `default` after copying it, +> and `Append` resets the handler after assigning it back, so exactly one copy owns the pooled array at +> any instant. Without the first reset the command spends the append window as a second owner, still +> pointing at an array that a growth may already have returned to the pool; the reset makes an escape from +> that window — an exception mid-fragment — leave an empty command rather than a live-looking one. +> +> `default` rather than merely clearing the buffer, because `_hasCommand` goes false with it: every path +> off a moved-from handler is then a clean throw naming the problem (`Complete`, every `AppendFormatted`) +> or a no-op (`Dispose`, so no double return to the pool), and none of them is a `NullReferenceException`. +> This is the ownership-transfer idiom `Complete` already used for handing the buffer to a frame. +> +> That reset is also the reason the constructor parameter is `ref` rather than `in`. `in` compiles — the +> constructor genuinely only reads — and it was briefly the signature on those grounds; writing the move +> down properly made `ref` the accurate one. Both mutants (dropping either reset) are caught by test. > > **There is only one handler type**, which is what makes this safe rather than merely neat. A separate > proxy type would need its `AppendFormatted` overloads kept in step with the command handler's - and the diff --git a/src/StackExchange.Redis/Interpolated/RespAppend.cs b/src/StackExchange.Redis/Interpolated/RespAppend.cs index d54e17d02..53d6608f8 100644 --- a/src/StackExchange.Redis/Interpolated/RespAppend.cs +++ b/src/StackExchange.Redis/Interpolated/RespAppend.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using RESPite; @@ -28,6 +28,12 @@ public static class RespAppend /// keeping two lists aligned. /// /// + /// The handler is reset to default once its value has been taken, closing the other half of + /// the move begun by the constructor. The compiler's temporary is dead at this point either way, so + /// this buys nothing on the happy path - it is here so that any future caller who names the handler + /// itself finds an empty one rather than a second owner of a live pooled array. + /// + /// /// An extension with an explicit ref parameter, not an instance method: as an instance /// method the compiler must pass ref this into the handler's constructor and then refuses /// the call (CS8350/CS8352), because it cannot see that the reference does not escape. The @@ -37,6 +43,9 @@ public static class RespAppend public static void Append( this ref RespCommandHandler command, [InterpolatedStringHandlerArgument(nameof(command))] ref RespCommandHandler handler) - => command = handler; + { + command = handler; + handler = default; + } } } diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index b2e97fe93..1b0542836 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -312,17 +312,29 @@ public void AppendFormatted(RespCommand value) /// CS9050, a ref field cannot refer to a ref struct, on every target. /// /// - /// It moves rather than shares. The command is copied in here, appended to, and assigned - /// back by Append. Both copies reference the same pooled array in between, but only this one - /// is touched, and the original is overwritten as the window closes - including when a growth - /// inside the window swapped the array, which is what separates a move from a share. + /// It moves rather than shares, and the move is completed at both ends: the source is reset + /// to default here, and the handler is reset by Append once it has been assigned back. + /// So exactly one copy owns the pooled array at any instant, and the copy left behind cannot be + /// used to reach an array that a growth inside the window has already returned to the pool. + /// + /// + /// default rather than merely clearing the buffer, because every path off a moved-from + /// handler is then a clean throw rather than a : _hasCommand + /// is false, so and every AppendFormatted say what went wrong, and + /// is a no-op instead of a double return to the pool. This is the same + /// ownership-transfer idiom uses. + /// + /// + /// The parameter is ref and not in for exactly that reset; reading alone would be + /// satisfied by in, and the compiler accepts either. /// /// - public RespCommandHandler(int literalLength, int formattedCount, scoped in RespCommandHandler command) + public RespCommandHandler(int literalLength, int formattedCount, scoped ref RespCommandHandler command) { _ = literalLength; _ = formattedCount; this = command; + command = default; // the move is only a move if the source stops owning the buffer } /// Append a key: prefixed, marked for invalidation, and folded into the cluster slot. diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index deb54b2eb..5eb70e946 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.CommandFlags StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.IRespHandler @@ -54,7 +54,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, StackExchange.Redis.Interpolated.RespContext context, string! command) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, scoped in StackExchange.Redis.Interpolated.RespCommandHandler command) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.RespCommandHandler(int literalLength, int formattedCount, scoped ref StackExchange.Redis.Interpolated.RespCommandHandler command) -> void [SER010]StackExchange.Redis.Interpolated.RespCommands [SER010]StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.Cache.get -> StackExchange.Redis.Interpolated.RespClientCache? diff --git a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs index aba38c88d..d0b4a358f 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Reflection; using System.Text; @@ -69,6 +69,45 @@ public void AppendSurvivesABufferGrowth() Assert.Equal(4, frame.ArgCount); } + [Fact] + public void TheSourceIsEmptiedForTheDurationOfTheAppend() + { + // the move is completed at both ends: the constructor resets the source, so the command is not a + // second owner of the pooled array while the fragment is being written. Spelled out here the way + // the compiler spells it, because that window is not observable from `cmd.Append($"...")`. + var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}"); + + var handler = new RespCommandHandler(0, 1, ref cmd); + + // cmd now owns nothing: every path off it is a clean throw or a no-op, never a double-free. + // Spelled as try/catch rather than Assert.Throws because a ref struct cannot be captured by a lambda. + Assert.True(CompleteThrows(ref cmd), "the moved-from command should be empty"); + cmd.Dispose(); // no-op; would be a second return to the pool if the reset had not happened + + handler.AppendFormatted((RedisValue)"v"); + RespAppend.Append(ref cmd, ref handler); + + // ...and the other end: the handler has been emptied in turn + Assert.True(CompleteThrows(ref handler), "the moved-from handler should be empty"); + + using var frame = Ctx.Execute(ref cmd); + Assert.Equal("*3|$3|SET|$1|k|$1|v|", Text(frame)); + } + + /// Whether Complete rejects this handler as empty, without disturbing it if it does. + private static bool CompleteThrows(ref RespCommandHandler handler) + { + try + { + handler.Complete().Dispose(); + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + [Fact] public void SeveralAppendsAccumulate() { From 855e41cde9b5f44cf497a0136b0a05de7167b4b6 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 13:30:03 +0100 Subject: [PATCH 081/360] Optional and custom arguments as holes: the full SET Two related additions, both about arguments whose token count is not one. 1. Expiration and ValueCondition get AppendFormatted overloads, so they render 0-3 tokens each and the whole of SET is one interpolation: $"{RedisCommand.SET}{key}{value}{when}{expiry}" No branch, because an absent optional argument is *no* argument rather than an empty one. This is the first place the design beats the existing code rather than matching it: GetStringSetMessage is a ~17-branch tree, and most of those branches only pick between fixed-arity Message.Create overloads - one per token count. Arity is free here. Condition before expiration, per the documented grammar; Redis parses the tail order-insensitively (hence the legacy `EX n XX`) but other RESP servers need not. The new surface emits canonical SET only: SETEX/PSETEX are arity relics, but dropping SETNX is a real divergence (`:1`/`:0` vs `+OK`/nil), taken deliberately. The mode/keyword selection now lives once, in Expiration.OperandResp and ValueCondition.KeywordResp, with each writer doing only its own plumbing - so the MessageWriter and handler paths cannot disagree about what an Expiration means. Tested by rendering the same command through both and comparing bytes across the matrix. 2. IRespArgument + `AppendFormatted(T) where T : IRespArgument`, the only way another assembly can put its own type in a hole (extension AppendFormatted does not bind - the lowering stops at instance members). The notes said do not define AppendFormatted. That still holds for an unconstrained one; the constraint is the exception, and the three cases that decide it were measured, not assumed: a dedicated overload still wins, an implicit conversion does not, and a non-implementer still fails to compile - with a better diagnostic than before (CS0315 names the interface). A struct implementer is a constrained call: zero allocations, asserted. An implementer cannot miscount, because it writes through the handler's own counters - unlike RespFragment.ArgCount, which is taken on trust. Notes updated: 2.2 (supersedes the blanket ban and the "nobody can extend it" claim) and 4.1 (optional arguments did not need Append after all). --- design/interpolated-resp-writer.md | 73 +++++++++- src/StackExchange.Redis/Expiration.cs | 63 +++++---- .../Interpolated/IRespArgument.cs | 42 ++++++ .../Interpolated/RespCommandHandler.cs | 108 ++++++++++++++ .../Interpolated/RespSurface.cs | 60 +++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 9 +- src/StackExchange.Redis/ValueCondition.cs | 55 ++++---- .../InterpolatedCustomArgTests.cs | 106 ++++++++++++++ .../InterpolatedOptionalArgTests.cs | 132 ++++++++++++++++++ .../RespSurfaceTests.cs | 6 +- 10 files changed, 590 insertions(+), 64 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/IRespArgument.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs create mode 100644 tests/StackExchange.Redis.Tests/InterpolatedOptionalArgTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index cab3383b6..7a0ad7229 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -290,17 +290,51 @@ extension(ref H h) { public void AppendFormatted(Blob v) } // no (C# 14 extensio ``` The lowering does member lookup against instance members declared on the handler type and stops. -So nobody — not a consumer, not another assembly here — can extend it after the fact. +So nobody — not a consumer, not another assembly here — can extend it after the fact **by adding a +method**. `IRespArgument` (below) is the sanctioned way back in. Consequences: - Prefer a **few correct funnels over an enumeration**. Adding overloads later is additive and safe; removing or retyping them is breaking (AGENTS.md). Ship the minimum set. - The funnels: `RedisCommand`, `RedisKey`, `RedisValue`, `Resp.Raw`. -- **Do not define `AppendFormatted`.** A generic catch-all is an exact match by inference, so it - beats any overload needing a conversion — anything not explicitly declared silently falls into a - `ToString()` path and goes on the wire wrong. Omitting it makes those compile errors instead. - (Cost: the resulting `CS1503` names an arbitrary overload from the set. Analyzer candidate.) +- **Do not define an *unconstrained* `AppendFormatted`.** A generic catch-all is an exact match by + inference, so it beats any overload needing a conversion — anything not explicitly declared silently + falls into a `ToString()` path and goes on the wire wrong. Omitting it makes those compile errors + instead. + +**The constrained form is the exception, and is now implemented:** + +```csharp +public void AppendFormatted(T value) where T : IRespArgument +``` + +The constraint is the whole difference. A type that does not implement the interface is **not +applicable**, so the undeclared cases still fail to compile — and the diagnostic gets *better*, not +worse: `CS0315` naming `IRespArgument` and what to do about it, where the closed overload set produced a +`CS1503` naming an arbitrary member (the "analyzer candidate" this bullet used to end with is +consequently no longer needed). + +Overload resolution measured on the three cases that decide whether it is safe: + +| both applicable | winner | verdict | +|---|---|---| +| dedicated non-generic overload vs. the generic | **non-generic** | wanted; built-ins keep their own rendering | +| implicit conversion to `RedisValue` vs. the generic | **generic** | wanted; opting in beats an incidental conversion | +| type implementing nothing (`Guid`) | *neither* — CS0315 | wanted; the protection above survives | + +A `struct` implementer is a constrained call, so **nothing boxes** — pinned by an allocation test +asserting exactly zero. + +**An implementer cannot miscount.** It writes by calling the handler's own `AppendFormatted` methods, +which maintain `_args`/`_argIndex`, so there is no separately declared token count to drift from what was +actually written. Contrast `RespFragment.ArgCount`, which is an assertion taken on trust — a wrong one +corrupts the `*N` header and misframes the *next* command on the connection. Writing nothing is legal and +means "no argument". + +This is the argument-level counterpart to §9.4: the context is the extension point for *commands*, and +`IRespArgument` is the extension point for *argument types*. Without it, `NRedisStack` could add commands +but could not add a type that appears in one. - With no catch-all, `RedisValue`'s existing implicit conversions cover `string`, `int`, `byte[]` etc. for free. @@ -697,6 +731,35 @@ works, or callers are forced into `try`/`finally`. > construction. (That guard was written, as a reflection test, before the single-type version replaced the > need for it.) > +> **Optional arguments did not need `Append` after all.** `Append` was built for `if (cond) cmd.Append(...)`, +> and it is still the right tool for a fragment whose *presence* is a branch in the caller's own logic. But +> an argument that knows it might be absent can just say so: `AppendFormatted(Expiration)` and +> `AppendFormatted(ValueCondition)` write between zero and three tokens, so the whole of SET is +> +> ```csharp +> $"{RedisCommand.SET}{key}{value}{when}{expiry}" +> ``` +> +> with no branch at all. That is the first place the design pays for itself against the existing code rather +> than merely matching it: `RedisDatabase.GetStringSetMessage` is a ~17-branch decision tree, and most of +> those branches are not about Redis — they pick between fixed-arity `Message.Create` overloads, one branch +> per token count. Arity is free here, so they evaporate. +> +> Order is the documented grammar, `SET key value [NX|XX|IFEQ cmp] [GET] [EX s|...|KEEPTTL]`, i.e. condition +> before expiration. Redis parses the tail as an order-insensitive loop — which is how the legacy builder +> gets away with emitting `EX n XX` — but other RESP servers need not be as forgiving. +> +> **The new surface emits canonical `SET` only**, where the legacy builder also reaches for `SETNX`, +> `SETEX`, `PSETEX` and `DEL`. `SETEX`/`PSETEX` are pure arity relics with identical semantics and reply. +> `SETNX` is **not** a relic — it answers `:1`/`:0` where `SET ... NX` answers `+OK`/nil — so collapsing it +> is a real, deliberate divergence: `SET ... NX` has been available since 2.6.12, and one reply shape beats +> two. +> +> **The operand tokens have one home.** `Expiration.OperandResp` and `ValueCondition.KeywordResp` hold the +> mode/keyword selection, and each writer does only its own plumbing around them, so the `MessageWriter` +> path and the handler path cannot disagree about what an `Expiration` *means*. Pinned by a test that +> renders the same command through both writers and compares bytes, across the whole matrix. +> > Two things make it legal, both worth knowing because the errors are opaque: > `Append` is an **extension** with an explicit `ref` parameter rather than an instance method, because as > an instance method the compiler must pass `ref this` into the handler's constructor and then refuses the diff --git a/src/StackExchange.Redis/Expiration.cs b/src/StackExchange.Redis/Expiration.cs index 9c747f1bf..67ef4e559 100644 --- a/src/StackExchange.Redis/Expiration.cs +++ b/src/StackExchange.Redis/Expiration.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace StackExchange.Redis; @@ -275,36 +275,51 @@ internal int GetTokenCount(bool allowEnx) static int ThrowEnxNotSupported() => throw new NotSupportedException("ENX is not supported for this command."); } - internal void WriteTo(in MessageWriter writer) + /// + /// The already-framed RESP token naming the mode - EX, PXAT, KEEPTTL, ... - or + /// empty when this expiration contributes no arguments at all. + /// + /// + /// Shared by every writer rather than restated per writer: this switch is the whole of the mode + /// selection, and it is the part that would silently diverge if each writer kept its own copy. + /// says whether a numeric operand follows it. + /// + internal ReadOnlySpan OperandResp { - if (IsNone) + get { - return; + if (IsNone) return default; + if (IsKeepTtl) return "$7\r\nKEEPTTL\r\n"u8; + if (IsPersist) return "$7\r\nPERSIST\r\n"u8; + return (_flags & (ExpirationState.IsAbsolute | ExpirationState.IsMillis)) switch + { + ExpirationState.IsAbsolute | ExpirationState.IsMillis => "$4\r\nPXAT\r\n"u8, + ExpirationState.IsAbsolute => "$4\r\nEXAT\r\n"u8, + ExpirationState.IsMillis => "$2\r\nPX\r\n"u8, + _ => "$2\r\nEX\r\n"u8, + }; } + } - if (IsKeepTtl) - { - writer.WriteRaw("$7\r\nKEEPTTL\r\n"u8); - return; - } + /// Whether is followed by a numeric . + /// False for KEEPTTL and PERSIST, which are complete in themselves. + internal bool HasExpirationValue => (_flags & ExpirationState.HasExpiration) != 0; - if (IsPersist) - { - writer.WriteRaw("$7\r\nPERSIST\r\n"u8); - return; - } + /// The already-framed RESP token for ENX, or empty when it does not apply. + internal ReadOnlySpan ExpireIfNotExistsResp + => HasExpirationValue && IsExpireIfNotExists ? "$3\r\nENX\r\n"u8 : default; - writer.WriteRaw((_flags & (ExpirationState.IsAbsolute | ExpirationState.IsMillis)) switch - { - ExpirationState.IsAbsolute | ExpirationState.IsMillis => "$4\r\nPXAT\r\n"u8, - ExpirationState.IsAbsolute => "$4\r\nEXAT\r\n"u8, - ExpirationState.IsMillis => "$2\r\nPX\r\n"u8, - _ => "$2\r\nEX\r\n"u8, - }); - writer.WriteBulkString(Value); - if (IsExpireIfNotExists) + internal void WriteTo(in MessageWriter writer) + { + var operand = OperandResp; + if (operand.IsEmpty) return; + + writer.WriteRaw(operand); + if (HasExpirationValue) { - writer.WriteRaw("$3\r\nENX\r\n"u8); + writer.WriteBulkString(Value); + var enx = ExpireIfNotExistsResp; + if (!enx.IsEmpty) writer.WriteRaw(enx); } } } diff --git a/src/StackExchange.Redis/Interpolated/IRespArgument.cs b/src/StackExchange.Redis/Interpolated/IRespArgument.cs new file mode 100644 index 000000000..b7586a339 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/IRespArgument.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Implemented by a type that knows how to write itself as one or more RESP + /// arguments, so that it can be used directly in a command hole: $"{cmd}{key}{myArgument}". + /// + /// + /// + /// This is the only way the vocabulary of holes can be extended from outside this assembly. + /// Extension AppendFormatted methods do not bind - the interpolated-string lowering does member + /// lookup against instance members declared on the handler type and stops - so without this, the set of + /// things that can appear in a hole is closed, and closed to us. See design notes section 2.2. + /// + /// + /// An implementation cannot miscount. It writes by calling the handler's own + /// AppendFormatted methods, which maintain the argument counters, so there is no separately + /// declared token count to fall out of step with what was actually written - unlike + /// , whose is an assertion the writer + /// takes on trust. + /// + /// + /// Writing nothing is legal and means "no argument", which is how an absent optional argument is + /// spelled; see for the same idea on a + /// built-in type. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public interface IRespArgument + { + /// Write this value as zero or more RESP arguments. + /// The command being written; append to it via its AppendFormatted methods. + /// + /// The parameter is scoped ref: ref because the handler is a mutable + /// ref struct that must not be copied, and scoped because that is what lets the + /// handler pass ref this in without the compiler rejecting the call (CS8350/CS8352). + /// + void WriteTo(scoped ref RespCommandHandler handler); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 1b0542836..0f5a248fc 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -383,6 +383,114 @@ public void AppendFormatted(RedisChannel value) _argIndex++; } + /// + /// Append an expiration: EX 300, PXAT 1700000000000, KEEPTTL, or - for + /// - nothing at all. + /// + /// The expiration to append. + /// + /// + /// The first argument type that writes a variable number of tokens, including zero. That is + /// the whole reason the optional parts of a command can be written as holes rather than as branches: + /// $"{expiry}{when}" renders to between zero and five arguments and the handler keeps the + /// count straight, where the fixed-arity Message.Create overloads needed a branch per shape. + /// + /// + /// The mode token itself comes from , shared with the + /// MessageWriter path, so the two writers cannot disagree about what an + /// means. + /// + /// + public void AppendFormatted(Expiration value) + { + DemandCommand(); + + var operand = value.OperandResp; + if (operand.IsEmpty) return; // Expiration.Default contributes no arguments + + AppendPreframed(operand); + if (value.HasExpirationValue) + { + AppendFormatted((RedisValue)value.Value); + var enx = value.ExpireIfNotExistsResp; + if (!enx.IsEmpty) AppendPreframed(enx); + } + } + + /// + /// Append a value condition: NX, XX, IFEQ v, IFDNE 0a1b..., or - for + /// - nothing at all. + /// + /// The condition to append. + /// + /// As : variable token count, and the keyword comes from + /// so both writers agree. + /// + public void AppendFormatted(ValueCondition value) + { + DemandCommand(); + + var keyword = value.KeywordResp; + if (keyword.IsEmpty) return; // ValueCondition.Always contributes no arguments + + AppendPreframed(keyword); + if (value.IsValueTest) + { + AppendFormatted(value.Value); + } + else if (value.IsDigestTest) + { + // the wire form is hex of the big-endian digest bytes, NOT the int64 the RedisValue holds + Span hex = stackalloc byte[2 * ValueCondition.DigestBytes]; + var written = ValueCondition.WriteHex(value.Value.OverlappedValueInt64, hex); + var payload = WriteBulk(written.Length, out var payloadOffset); + written.CopyTo(payload); + CommitBulk(payloadOffset, written.Length); + _args++; + _argIndex++; + } + } + + /// + /// Append any type that knows how to write itself, so the set of things that can appear in a hole + /// is open to other assemblies rather than closed to this one. + /// + /// The argument type; inferred from the hole. + /// The argument to append. + /// + /// + /// The design notes (section 2.2) say do not define AppendFormatted<T>, and that + /// still holds for an unconstrained one: it is an exact match by inference, so it would beat + /// every overload needing a conversion and quietly swallow anything undeclared into a + /// ToString() path. The constraint is what makes this the exception rather than a reversal - + /// a type that does not implement is not applicable at all, so it still + /// fails to compile, and with a better diagnostic than before (CS0315 names the interface, + /// where the closed overload set produced a CS1503 naming an arbitrary member). + /// + /// + /// Measured, not assumed, on the three cases that decide whether this is safe: a dedicated + /// non-generic overload still wins when both apply; an implicit conversion does not win + /// (opting in beats an incidental conversion, which is the wanted answer); and a struct + /// implementer is a constrained call, so nothing boxes. + /// + /// + public void AppendFormatted(T value) where T : IRespArgument + { + DemandCommand(); + if (value is null) throw new ArgumentNullException(nameof(value)); + value.WriteTo(ref this); + } + + /// Copy one already-framed single-token literal in, advancing the counters by one. + private void AppendPreframed(scoped ReadOnlySpan framed) + { + Ensure(framed.Length); + framed.CopyTo(_buffer.AsSpan(_offset)); + _offset += framed.Length; + _args++; + _argIndex++; + } + /// /// Write an already-framed fragment verbatim. Note the argument counters advance by /// , not by one, so a multi-token fragment does not shift the diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 86b085e99..9151851b3 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using RESPite; @@ -147,13 +147,63 @@ public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags => strings.Context.SendAsync( $"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly)); - /// SET. + /// SET, in full: expiration and value condition included. /// The key to write. /// The value to write. + /// When the key should expire; default for no expiration. + /// The condition the write is subject to; default to write unconditionally. /// Command flags. - public ValueTask Set(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => strings.Context.SendAsync( - $"{RedisCommand.SET}{key}{value}", flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins)); + /// + /// + /// Deliberately the most complicated command in the spike, because it is the one that + /// tests the design rather than demonstrating it: between them and + /// render anywhere from zero to five extra arguments, so the command's + /// arity is not known until run time. + /// + /// + /// It is still one straight line of writing. The legacy builder + /// (RedisDatabase.GetStringSetMessage) is a ~17-branch decision tree, and most of those + /// branches are not about Redis at all - they pick between fixed-arity Message.Create + /// overloads, one branch per token count, which is a cost the interpolated form simply does not + /// have. + /// + /// + /// It emits the canonical SET and nothing else, where the legacy builder also + /// reaches for SETNX, SETEX and PSETEX. SETEX/PSETEX are pure + /// arity relics - identical semantics and reply to SET ... EX n. SETNX is + /// not: it replies :1/:0 where SET ... NX replies +OK/nil, so + /// collapsing it here is a real divergence from the old surface, taken deliberately - + /// SET ... NX has been available since 2.6.12 and one reply shape beats two. + /// + /// + /// Condition before expiration, which is the documented grammar: + /// SET key value [NX|XX|IFEQ cmp] [GET] [EX s|PX ms|EXAT|PXAT|KEEPTTL]. Redis itself + /// parses the tail as an order-insensitive loop - which is how the legacy builder gets away with + /// emitting EX n XX - but other RESP servers need not be as forgiving, and matching the + /// documentation costs nothing. + /// + /// + /// The retry category comes from the condition: a conditional write is checked, an + /// unconditional one is last-wins. returns + /// for "no opinion", and WithRetryCategory is first-wins, + /// so a caller who names a category still keeps it. + /// + /// + public ValueTask Set( + RedisKey key, + RedisValue value, + Expiration expiry = default, + ValueCondition when = default, + CommandFlags flags = CommandFlags.None) + { + var context = strings.Context; + flags = flags.WithRetryCategory(when.RetryCategory) + .WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); + + var command = context.Compose($"{RedisCommand.SET}{key}{value}{when}{expiry}"); + var frame = command.Complete(); + return context.SendAsync(ref frame, flags, RespHandlers.Ok); + } } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 5eb70e946..7c1b12210 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -46,6 +46,11 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Expiration value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value) -> void +[SER010]StackExchange.Redis.Interpolated.IRespArgument +[SER010]StackExchange.Redis.Interpolated.IRespArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.ValueCondition value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void @@ -139,7 +144,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! @@ -158,7 +163,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index f035551b6..33abea6e9 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -280,34 +280,39 @@ static byte ToNibble(int b) _ => 0, }; + /// + /// The already-framed RESP keyword for this condition - NX, IFDEQ, ... - or empty when + /// the condition contributes no arguments. + /// + /// + /// Shared by every writer rather than restated per writer; see the same note on + /// . and say + /// what follows it, and in which encoding. + /// + internal ReadOnlySpan KeywordResp => _kind switch + { + ConditionKind.Exists => "$2\r\nXX\r\n"u8, + ConditionKind.NotExists => "$2\r\nNX\r\n"u8, + ConditionKind.ValueEquals => "$4\r\nIFEQ\r\n"u8, + ConditionKind.ValueNotEquals => "$4\r\nIFNE\r\n"u8, + ConditionKind.DigestEquals => "$5\r\nIFDEQ\r\n"u8, + ConditionKind.DigestNotEquals => "$5\r\nIFDNE\r\n"u8, + _ => default, + }; + internal void WriteTo(in MessageWriter writer) { - switch (_kind) + var keyword = KeywordResp; + if (keyword.IsEmpty) return; + + writer.WriteRaw(keyword); + if (IsValueTest) { - case ConditionKind.Exists: - writer.WriteRaw("$2\r\nXX\r\n"u8); - break; - case ConditionKind.NotExists: - writer.WriteRaw("$2\r\nNX\r\n"u8); - break; - case ConditionKind.ValueEquals: - writer.WriteRaw("$4\r\nIFEQ\r\n"u8); - writer.WriteBulkString(_value); - break; - case ConditionKind.ValueNotEquals: - writer.WriteRaw("$4\r\nIFNE\r\n"u8); - writer.WriteBulkString(_value); - break; - case ConditionKind.DigestEquals: - writer.WriteRaw("$5\r\nIFDEQ\r\n"u8); - var written = WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes]); - writer.WriteBulkString(written); - break; - case ConditionKind.DigestNotEquals: - writer.WriteRaw("$5\r\nIFDNE\r\n"u8); - written = WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes]); - writer.WriteBulkString(written); - break; + writer.WriteBulkString(_value); + } + else if (IsDigestTest) + { + writer.WriteBulkString(WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes])); } } diff --git a/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs new file mode 100644 index 000000000..750a9266d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// : the one way a type from another assembly can appear in a command hole. +/// +/// +/// Every assertion here is an overload-resolution fact that was measured rather than reasoned about, +/// because the design notes (2.2) previously ruled out AppendFormatted<T> outright and the +/// constrained form is only an exception to that if these come out the way they do. +/// +public class InterpolatedCustomArgTests +{ + private static readonly RespContext Ctx = new(); + + private static string Text(in RespFrame frame) => + Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + /// A struct, so the constrained call has something to box if it is going to. + private readonly struct Window(int from, int to) : IRespArgument + { + public void WriteTo(scoped ref RespCommandHandler handler) + { + handler.AppendFormatted((RedisValue)from); + handler.AppendFormatted((RedisValue)to); + } + } + + /// Writes nothing: an absent optional argument is no argument, not an empty one. + private readonly struct Absent : IRespArgument + { + public void WriteTo(scoped ref RespCommandHandler handler) { } + } + + /// Opts in AND converts to , so both overloads are applicable. + private readonly struct Ambiguous : IRespArgument + { + public static implicit operator RedisValue(Ambiguous value) => "CONVERSION"; + public void WriteTo(scoped ref RespCommandHandler handler) => handler.AppendFormatted((RedisValue)"INTERFACE"); + } + + [Fact] + public void ACustomTypeCanAppearInAHole() + { + using var frame = Ctx.Execute($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); + Assert.Equal("*4|$6|ZRANGE|$1|k|$1|0|$1|9|", Text(frame)); + Assert.Equal(4, frame.ArgCount); + } + + [Fact] + public void WritingNothingContributesNoArgument() + { + using var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Absent()}"); + Assert.Equal("*2|$3|GET|$1|k|", Text(frame)); + Assert.Equal(2, frame.ArgCount); + } + + [Fact] + public void AStructImplementerDoesNotBox() + { + // a constrained call on a value type, so the interface dispatch costs no allocation; if this + // regresses to a boxing call it is one allocation per argument per command, on the hot path + static long Measure() + { + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 64; i++) + { + using var frame = Ctx.Execute($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); + } + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + Measure(); // discard the first pass: buffer rental warms the pool + Assert.Equal(0, Measure()); + } + + [Fact] + public void OptingInBeatsAnIncidentalConversion() + { + // both AppendFormatted(RedisValue) (via the implicit operator) and AppendFormatted apply; the + // generic is an exact match by inference and wins. That is the WANTED answer here - implementing + // the interface is a deliberate statement about how the type should be written - but it is the + // same mechanism the design notes warn about for an unconstrained generic, so it is pinned. + using var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Ambiguous()}"); + Assert.Equal("*3|$3|GET|$1|k|$9|INTERFACE|", Text(frame)); + } + + [Fact] + public void AKeyAfterACustomArgumentIsStillMarkedCorrectly() + { + // the implementer writes through the handler's own counters, so it cannot misreport how many + // arguments it wrote - which is what would otherwise shift every key mark after it + using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{new Window(0, 9)}{(RedisKey)"b"}"); + + Assert.Equal(5, frame.ArgCount); + Assert.Equal(2, frame.KeyCount); + var ranges = new KeyRange[2]; + Assert.Equal(2, frame.TryGetKeys(ranges)); + Assert.Equal("a", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + Assert.Equal("b", Encoding.UTF8.GetString(frame.GetKey(ranges[1]).ToArray())); + } +} diff --git a/tests/StackExchange.Redis.Tests/InterpolatedOptionalArgTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedOptionalArgTests.cs new file mode 100644 index 000000000..edad87fe2 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InterpolatedOptionalArgTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Text; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Optional arguments as holes: Expiration and ValueCondition render between zero and three +/// tokens each, so $"{cmd}{key}{value}{when}{expiry}" covers the whole of SET without a branch. +/// +/// +/// These are the first argument types whose token count is not one, and not known until run time. The +/// invariant that matters is that the count they advertise (TokenCount/GetTokenCount) is the +/// count they actually write - a disagreement corrupts the *N header and desynchronises the whole +/// connection, not just the one command. +/// +public class InterpolatedOptionalArgTests +{ + private static readonly RespContext Ctx = new(); + + private static string Text(in RespFrame frame) => + Encoding.UTF8.GetString(frame.Span.ToArray()).Replace("\r\n", "|"); + + /// The interpolated writer's rendering of a full SET. + private static RespFrame ViaHandler(RedisKey key, RedisValue value, ValueCondition when, Expiration expiry) + { + var cmd = Ctx.Compose($"{RedisCommand.SET}{key}{value}{when}{expiry}"); + return cmd.Complete(); + } + + /// The same command through the legacy MessageWriter, using the types' own WriteTo. + private static RespFrame ViaMessageWriter(RedisKey key, RedisValue value, ValueCondition when, Expiration expiry) + { + var sink = new RespFrameWriter(); + var writer = new MessageWriter(null, CommandMap.Default, sink); + writer.WriteHeader(RedisCommand.SET, 2 + when.TokenCount + expiry.GetTokenCount(allowEnx: true)); + writer.Write(key); + writer.WriteBulkString(value); + when.WriteTo(writer); + expiry.WriteTo(writer); + return sink.Complete(ServerSelectionStrategy.NoSlot); + } + + public static TheoryData Cases() => new() + { + // name condition expiry expected tail + { "bare", ValueCondition.Always, Expiration.Default, "" }, + { "nx", ValueCondition.NotExists, Expiration.Default, "$2|NX|" }, + { "xx", ValueCondition.Exists, Expiration.Default, "$2|XX|" }, + { "ifeq", ValueCondition.Equal("old"), Expiration.Default, "$4|IFEQ|$3|old|" }, + { "ifne", ValueCondition.NotEqual("old"), Expiration.Default, "$4|IFNE|$3|old|" }, + { "ex", ValueCondition.Always, new Expiration(TimeSpan.FromSeconds(300)), "$2|EX|$3|300|" }, + { "px", ValueCondition.Always, new Expiration(TimeSpan.FromMilliseconds(1500)), "$2|PX|$4|1500|" }, + { "keepttl", ValueCondition.Always, Expiration.KeepTtl, "$7|KEEPTTL|" }, + { "persist", ValueCondition.Always, Expiration.Persist, "$7|PERSIST|" }, + { "enx", ValueCondition.Always, new Expiration(TimeSpan.FromSeconds(60), ExpirationFlags.ExpireIfNotExists), "$2|EX|$2|60|$3|ENX|" }, + { "nx+ex", ValueCondition.NotExists, new Expiration(TimeSpan.FromSeconds(300)), "$2|NX|$2|EX|$3|300|" }, + { "ifeq+keepttl", ValueCondition.Equal("old"), Expiration.KeepTtl, "$4|IFEQ|$3|old|$7|KEEPTTL|" }, + }; + + [Theory] + [MemberData(nameof(Cases))] + public void TheFullSetRendersInTheDocumentedOrder(string name, ValueCondition when, Expiration expiry, string tail) + { + _ = name; + using var frame = ViaHandler("k", "v", when, expiry); + Assert.Equal("*" + frame.ArgCount + "|$3|SET|$1|k|$1|v|" + tail, Text(frame)); + } + + [Theory] + [MemberData(nameof(Cases))] + public void BothWritersAgreeByteForByte(string name, ValueCondition when, Expiration expiry, string tail) + { + _ = (name, tail); + + // the operand selection lives in ONE place (Expiration.OperandResp / ValueCondition.KeywordResp) + // and each writer only does its own plumbing; this is what holds those two halves together + using var viaHandler = ViaHandler("k", "v", when, expiry); + using var viaMessage = ViaMessageWriter("k", "v", when, expiry); + + Assert.Equal(Text(viaMessage), Text(viaHandler)); + Assert.Equal(viaMessage.ArgCount, viaHandler.ArgCount); + } + + [Theory] + [MemberData(nameof(Cases))] + public void TheAdvertisedTokenCountIsTheCountActuallyWritten(string name, ValueCondition when, Expiration expiry, string tail) + { + _ = (name, tail); + + // if these disagree the *N header is a lie, and the NEXT command on the connection is misframed - + // so this is the invariant worth pinning, not the rendering + using var frame = ViaHandler("k", "v", when, expiry); + Assert.Equal(3 + when.TokenCount + expiry.GetTokenCount(allowEnx: true), frame.ArgCount); + } + + [Fact] + public void DefaultsContributeNothingAtAll() + { + // the property that lets the optional parts be holes rather than branches: an absent argument is + // not an empty argument, it is no argument + using var frame = ViaHandler("k", "v", default, default); + Assert.Equal("*3|$3|SET|$1|k|$1|v|", Text(frame)); + Assert.Equal(3, frame.ArgCount); + } + + [Fact] + public void TheKeyIsStillMarkedAfterVariableLengthTails() + { + // key marking is by ARGUMENT INDEX, so a multi-token optional argument that miscounted would shift + // every mark after it; the key is before the tail here, but the count still has to survive + using var frame = ViaHandler("k", "v", ValueCondition.Equal("old"), new Expiration(TimeSpan.FromSeconds(300))); + + Assert.Equal(1, frame.KeyCount); + var ranges = new KeyRange[1]; + Assert.Equal(1, frame.TryGetKeys(ranges)); + Assert.Equal("k", Encoding.UTF8.GetString(frame.GetKey(ranges[0]).ToArray())); + } + + [Fact] + public void ADigestConditionIsSentAsHexNotAsTheInt64() + { + var digest = ValueCondition.DigestEqual("some value"); + using var viaHandler = ViaHandler("k", "v", digest, Expiration.Default); + using var viaMessage = ViaMessageWriter("k", "v", digest, Expiration.Default); + + var text = Text(viaHandler); + Assert.Contains("$5|IFDEQ|$16|", text); // 8 bytes of XXH3, hex-encoded + Assert.Equal(Text(viaMessage), text); + } +} diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index b899d8b86..98e72c2ee 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Threading; @@ -93,7 +93,7 @@ public async Task FlagsAreCumulativeRatherThanReplacing() // FireAndForget must not cost the command its retry category. It would have, when the category // lived in the parameter's DEFAULT value - passing any flag replaced it with nothing. - await target.Strings.Set("k", "v", CommandFlags.FireAndForget); + await target.Strings.Set("k", "v", flags: CommandFlags.FireAndForget); var sent = Assert.Single(executor.Flags); Assert.Equal(CommandFlags.FireAndForget, sent & CommandFlags.FireAndForget); @@ -107,7 +107,7 @@ public async Task AnExplicitCategoryStillWins() var target = Target(executor); // WithRetryCategory is first-wins, so a caller who names one keeps it - await target.Strings.Set("k", "v", CommandFlags.CommandRetryNever); + await target.Strings.Set("k", "v", flags: CommandFlags.CommandRetryNever); Assert.Equal(CommandFlags.CommandRetryNever, Assert.Single(executor.Flags) & Message.MaskRetryCategory); } From 7b8dcefdf1e4d575295c901861d599378bc875c0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 13:44:49 +0100 Subject: [PATCH 082/360] Split the string commands out; classic `this` extension methods RespSurface.Strings.cs now holds the RespStrings group struct, the two `Strings` accessors and the string commands, with RespSurface.cs left as IRespTarget + RespHandlers + the partial shell. One file per command group, the way Redis documents itself. The COMMANDS are ordinary `this in` extension methods rather than C# 14 extension blocks, and that is about retirement, not taste: deleting the `this` later un-binds new call sites while already-compiled callers keep working, because the static method is still there with the same name and signature - no MissingMethodException, no major version. An extension block member cannot be retired that gently. The group ACCESSORS stay as extension blocks because an extension property has no other spelling. `in` because RespStrings is a readonly struct. Set is now one expression: => strings.Context.SendAsync( $"{RedisCommand.SET}{key}{value}{when}{expiry}", flags...); Compose/Complete was left over from when the optional parts branched; they are holes now, so nothing branches. It keeps ValueTask because under NX/XX/IFEQ the boolean is real information - a nil reply means the write did not happen, which is not an error. Also adds a result-less SendAsync returning ValueTask, for the commands that do want it: no TResult to name, so a command body is just `=> ctx.SendAsync($"...", flags);`. It still READS the reply, via the new RespHandlers.Success, because with nothing returned a server error is the only thing such a call can report. A synchronous completion returns a default ValueTask and allocates nothing, which a plain async wrapper would have given up; asserted. --- .../Interpolated/RespExecutor.cs | 43 +++++- .../Interpolated/RespSurface.Strings.cs | 133 +++++++++++++++++ .../Interpolated/RespSurface.cs | 136 ++++-------------- .../PublicAPI/PublicAPI.Unshipped.txt | 9 +- .../StackExchange.Redis.csproj | 1 + .../RespSurfaceTests.cs | 38 +++++ 6 files changed, 243 insertions(+), 117 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index b30c8fdcf..dbaeff90d 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; @@ -230,6 +230,47 @@ public static ValueTask SendAsync( return SendAsync(context, ref frame, flags, handler ?? RespHandlers.Inbuilt.Require()); } + /// + /// Compose and send a command whose reply carries nothing worth reading: + /// await ctx.SendAsync($"{cmd}{key}", flags). + /// + /// The context to send through. + /// The command, written as an interpolated string. + /// The command's flags. + /// + /// + /// The result-less form removes the last piece of ceremony from a command that has no result: + /// there is no TResult to name, so there is no type argument, and a command body is just + /// => ctx.SendAsync($"...", flags);. + /// + /// + /// It still reads the reply - via - because a server + /// error is the only thing a call with no return value can report, and discarding the reply + /// wholesale would discard that too. + /// + /// + /// No ambiguity with the generic overloads: a result type cannot be inferred from a return type, + /// so an un-annotated call can only bind here, and a SendAsync<T> call can only bind + /// there. + /// + /// + /// A synchronously-completed send - notably a cache hit - returns a default + /// and allocates nothing, which is the same promise the generic overload + /// makes and would be lost by simply awaiting it in an async wrapper. + /// + /// + public static ValueTask SendAsync( + this RespContext context, + [InterpolatedStringHandlerArgument(nameof(context))] ref RespCommandHandler request, + CommandFlags flags = CommandFlags.None) + { + var frame = request.Complete(); + var pending = SendAsync(context, ref frame, flags, RespHandlers.Success); + return pending.IsCompletedSuccessfully ? default : Awaited(pending); + + static async ValueTask Awaited(ValueTask pending) => await pending.ConfigureAwait(false); + } + /// /// The context to send through. /// The command, written as an interpolated string. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs new file mode 100644 index 000000000..786ffb477 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -0,0 +1,133 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The string-command group: target.Strings.Set(...). + /// + /// + /// + /// A plain wrapper over one field - deliberately NOT a reinterpret-cast of a + /// layout-compatible struct. Holding exactly one field of that type makes the layout identical by + /// construction, so the wrapper IS the pun, enforced by the compiler and with no Unsafe. A + /// by-value pun would copy the same bytes anyway; only a ref pun avoids the copy, and that + /// requires a stable address, which drags ref readonly and its lifetime rules into every caller + /// to save a few register moves ahead of a network round trip. + /// + /// + /// Not a ref struct, for the same reason is not: these have to survive + /// an await. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespStrings + { + private readonly RespContext _context; + + /// Group the string commands of a context. + /// The context to send through. + public RespStrings(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The string commands. + public RespStrings Strings => new(target.Context); + } + + extension(in RespContext context) + { + /// The string commands. + public RespStrings Strings => new(context); + } + + // The group ACCESSORS above have to be extension blocks - an extension property has no other + // spelling. The COMMANDS below are deliberately ordinary `this` extension methods, and that is + // not nostalgia: it is what makes retiring one cheap later. Decommissioning an obsolete command + // is then a matter of deleting the `this` - new call sites bind to whatever replaced it, while + // already-compiled callers keep working, because the static method they were compiled against is + // still there, same name, same signature, same assembly. No MissingMethodException, no major + // version. An extension block member cannot be retired that gently. See AGENTS.md, "Backwards + // compatibility is paramount". + // + // `in` because RespStrings is a readonly struct: no defensive copy, and nothing to copy on the + // way to a network round trip. + + /// GET. + /// The string command group. + /// The key to read. + /// Command flags. + public static ValueTask Get(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly)); + + /// SET, in full: expiration and value condition included. + /// The string command group. + /// The key to write. + /// The value to write. + /// When the key should expire; default for no expiration. + /// The condition the write is subject to; default to write unconditionally. + /// Command flags. + /// + /// + /// Deliberately the most complicated command in the spike, because it is the one that tests + /// the design rather than demonstrating it: between them and + /// render anywhere from zero to five extra arguments, so the command's + /// arity is not known until run time. + /// + /// + /// It is still one straight line of writing. The legacy builder + /// (RedisDatabase.GetStringSetMessage) is a ~17-branch decision tree, and most of those + /// branches are not about Redis at all - they pick between fixed-arity Message.Create + /// overloads, one branch per token count, which is a cost the interpolated form simply does not + /// have. + /// + /// + /// Condition before expiration, which is the documented grammar: + /// SET key value [NX|XX|IFEQ cmp] [GET] [EX s|PX ms|EXAT|PXAT|KEEPTTL]. Redis itself parses + /// the tail as an order-insensitive loop - which is how the legacy builder gets away with emitting + /// EX n XX - but other RESP servers need not be as forgiving, and matching the documentation + /// costs nothing. + /// + /// + /// It emits the canonical SET and nothing else, where the legacy builder also reaches + /// for SETNX, SETEX and PSETEX. SETEX/PSETEX are pure arity + /// relics - identical semantics and reply to SET ... EX n. SETNX is not: it + /// replies :1/:0 where SET ... NX replies +OK/nil, so collapsing it here + /// is a real divergence from the old surface, taken deliberately - SET ... NX has been + /// available since 2.6.12 and one reply shape beats two. + /// + /// + /// One expression, including the optional arguments. Compose/Complete is not needed here - + /// that pairing exists for a fragment whose presence is a branch in the caller's logic, and + /// nothing here branches. The result stays ValueTask<bool> rather than the result-less + /// SendAsync, because under NX/XX/IFEQ the boolean is real information: a nil reply means + /// the write did not happen, which is not an error. + /// + /// + /// The retry category comes from the condition: a conditional write is checked, an unconditional + /// one is last-wins. returns + /// for "no opinion", and WithRetryCategory is first-wins, so + /// a caller who names a category still keeps it. + /// + /// + public static ValueTask Set( + this in RespStrings strings, + RedisKey key, + RedisValue value, + Expiration expiry = default, + ValueCondition when = default, + CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.SET}{key}{value}{when}{expiry}", + flags.WithRetryCategory(when.RetryCategory) + .WithRetryCategory(CommandFlags.CommandRetryWriteLastWins)); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 9151851b3..65a457628 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; using RESPite; using RESPite.Messages; @@ -29,36 +28,6 @@ public interface IRespTarget RespContext Context { get; } } - /// - /// EXPERIMENTAL SPIKE. The string-command group: target.Strings.Set(...). - /// - /// - /// - /// A plain wrapper over one field - deliberately NOT a reinterpret-cast of a - /// layout-compatible struct. Holding exactly one field of that type makes the layout identical by - /// construction, so the wrapper IS the pun, enforced by the compiler and with no Unsafe. A - /// by-value pun would copy the same bytes anyway; only a ref pun avoids the copy, and that - /// requires a stable address, which drags ref readonly and its lifetime rules into every caller - /// to save a few register moves ahead of a network round trip. - /// - /// - /// Not a ref struct, for the same reason is not: these have to survive - /// an await. - /// - /// - [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public readonly struct RespStrings - { - private readonly RespContext _context; - - /// Group the string commands of a context. - /// The context to send through. - public RespStrings(in RespContext context) => _context = context; - - /// The underlying context. - public RespContext Context => _context; - } - /// EXPERIMENTAL SPIKE. Reply handlers for the prototype command surface. [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] public static class RespHandlers @@ -69,6 +38,14 @@ public static class RespHandlers /// Reads a simple-string reply as success. public static IRespHandler Ok { get; } = new OkHandler(); + /// Checks the reply for a server error, and reads nothing else. + /// + /// What a command with no result still has to do. Without it a failed command would complete + /// quietly, because there would be no value whose absence gave the game away - the error is the + /// only thing such a call can report. + /// + public static IRespHandler Success { get; } = new SuccessHandler(); + /// The handler used when a call does not name one; resolved by result type. /// The result type. /// @@ -104,6 +81,16 @@ public RedisValue Parse(ReadOnlySpan response) } } + private sealed class SuccessHandler : IRespHandler + { + public bool Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); // skips attributes, and throws RespException on an error element + return true; + } + } + private sealed class OkHandler : IRespHandler { public bool Parse(ReadOnlySpan response) @@ -119,91 +106,18 @@ public bool Parse(ReadOnlySpan response) /// EXPERIMENTAL SPIKE. The command surface, as extension members. /// /// + /// /// This is the shape the whole design exists to enable: ctx.Strings.Set(key, value) reads like a /// built-in method, groups the surface the way Redis documents itself, and is reachable by any library - /// including one that is not this one - without a wrapper interface or a forked surface. + /// + /// + /// One partial file per command group - RespSurface.Strings.cs, and so on - matching how Redis + /// documents itself, and how RedisDatabase's ~6k lines would have liked to be split. + /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public static class RespSurface + public static partial class RespSurface { - extension(IRespTarget target) - { - /// The string commands. - public RespStrings Strings => new(target.Context); - } - - extension(in RespContext context) - { - /// The string commands. - public RespStrings Strings => new(context); - } - - extension(RespStrings strings) - { - /// GET. - /// The key to read. - /// Command flags. - public ValueTask Get(RedisKey key, CommandFlags flags = CommandFlags.None) - => strings.Context.SendAsync( - $"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly)); - - /// SET, in full: expiration and value condition included. - /// The key to write. - /// The value to write. - /// When the key should expire; default for no expiration. - /// The condition the write is subject to; default to write unconditionally. - /// Command flags. - /// - /// - /// Deliberately the most complicated command in the spike, because it is the one that - /// tests the design rather than demonstrating it: between them and - /// render anywhere from zero to five extra arguments, so the command's - /// arity is not known until run time. - /// - /// - /// It is still one straight line of writing. The legacy builder - /// (RedisDatabase.GetStringSetMessage) is a ~17-branch decision tree, and most of those - /// branches are not about Redis at all - they pick between fixed-arity Message.Create - /// overloads, one branch per token count, which is a cost the interpolated form simply does not - /// have. - /// - /// - /// It emits the canonical SET and nothing else, where the legacy builder also - /// reaches for SETNX, SETEX and PSETEX. SETEX/PSETEX are pure - /// arity relics - identical semantics and reply to SET ... EX n. SETNX is - /// not: it replies :1/:0 where SET ... NX replies +OK/nil, so - /// collapsing it here is a real divergence from the old surface, taken deliberately - - /// SET ... NX has been available since 2.6.12 and one reply shape beats two. - /// - /// - /// Condition before expiration, which is the documented grammar: - /// SET key value [NX|XX|IFEQ cmp] [GET] [EX s|PX ms|EXAT|PXAT|KEEPTTL]. Redis itself - /// parses the tail as an order-insensitive loop - which is how the legacy builder gets away with - /// emitting EX n XX - but other RESP servers need not be as forgiving, and matching the - /// documentation costs nothing. - /// - /// - /// The retry category comes from the condition: a conditional write is checked, an - /// unconditional one is last-wins. returns - /// for "no opinion", and WithRetryCategory is first-wins, - /// so a caller who names a category still keeps it. - /// - /// - public ValueTask Set( - RedisKey key, - RedisValue value, - Expiration expiry = default, - ValueCondition when = default, - CommandFlags flags = CommandFlags.None) - { - var context = strings.Context; - flags = flags.WithRetryCategory(when.RetryCategory) - .WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); - - var command = context.Compose($"{RedisCommand.SET}{key}{value}{when}{expiry}"); - var frame = command.Complete(); - return context.SendAsync(ref frame, flags, RespHandlers.Ok); - } - } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 7c1b12210..2794321cc 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -142,9 +142,6 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings) -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Get(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.RespStrings).Set(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! @@ -158,12 +155,14 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Ok.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Success.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 9bb67d90a..5199d9251 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -42,6 +42,7 @@ + diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs index 98e72c2ee..83b1f7c8a 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using RESPite; using StackExchange.Redis.Interpolated; using Xunit; @@ -40,6 +41,43 @@ public ValueTask SendAsync(RespRequest request, CancellationToken c private static RespDatabase Target(FakeExecutor executor, RespClientCache? cache = null) => new(new RespContext().WithExecutor(executor).WithCache(cache)); + [Fact] + public async Task AResultLessSendStillSurfacesAServerError() + { + // the whole reason the result-less form reads the reply at all: with nothing returned, an error is + // the ONLY thing the call can report, so discarding the reply would discard the failure too + var executor = new FakeExecutor("-ERR no such key\r\n"); + var context = new RespContext().WithExecutor(executor); + + await Assert.ThrowsAsync( + async () => await context.SendAsync($"{RedisCommand.DEL}{(RedisKey)"k"}")); + } + + [Fact] + public async Task AResultLessSendSendsTheSameBytes() + { + var executor = new FakeExecutor(":1\r\n"); + var context = new RespContext().WithExecutor(executor); + + await context.SendAsync($"{RedisCommand.DEL}{(RedisKey)"k"}", CommandFlags.FireAndForget); + + Assert.Equal("*2|$3|DEL|$1|k|", Assert.Single(executor.Sent)); + Assert.Equal(CommandFlags.FireAndForget, Assert.Single(executor.Flags) & CommandFlags.FireAndForget); + } + + [Fact] + public void AResultLessSendThatCompletesSynchronouslyAllocatesNoTask() + { + // the generic overload promises a synchronous completion costs no state machine and no Task; + // wrapping it in a plain `async ValueTask` would have quietly given that up + var executor = new FakeExecutor(":1\r\n"); + var context = new RespContext().WithExecutor(executor); + + var pending = context.SendAsync($"{RedisCommand.DEL}{(RedisKey)"k"}"); + Assert.True(pending.IsCompletedSuccessfully); + Assert.Equal(default, pending); + } + [Fact] public void AGroupStructCostsNothingOverTheContext() { From 33def894713fb10f72fe02c4f0f7ab69533f004e Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:05:51 +0100 Subject: [PATCH 083/360] Format specifiers; Expiration/ValueCondition go through the generic funnel IRespFormattableArgument handles $"{x:fmt}", behind its own AppendFormatted(T, string?) overload. Deliberately NOT derived from IRespArgument, so a type can implement either without the other - measured, both directions: IRespArgument only $"{x}" writes, $"{x:f}" is CS0315 IRespFormattableArgument $"{x}" is CS0315, $"{x:f}" writes both each spelling binds to its own arity The middle row is the reason for two interfaces rather than one: it makes the format MANDATORY, which is how a type with no safe default forces the caller to choose - the move the notes wanted when a bare $"{ttl}" would have to guess between EX and PX. A single interface cannot express it, and a DIM cannot fake it (no runtime support on net461/netstandard2.0). No alignment overload, and there must never be one: RESP is length-prefixed binary, so $"{key,10}" would pad the payload and send a DIFFERENT key, silently. CS1739 today, now pinned by a reflection test - a compile error cannot be asserted directly, and "add it for symmetry" is how it breaks. Expiration and ValueCondition now reach the writer through the generic funnel as explicit IRespArgument implementations, and their dedicated overloads are gone. They had those only because they were written before the funnel existed; if the mechanism is good enough for other libraries' types it should be good enough for ours. Explicit, so it stays out of the way of callers who will never write a frame by hand - and explicit implementations need no PublicAPI entries, so the surface shrinks rather than grows. Adds AppendBulk(ReadOnlySpan) for the one thing an implementation could not otherwise do without allocating: ValueCondition's digest hex lives in a stack buffer, and routing it through RedisValue would mean a byte[]. Named, not an AppendFormatted overload, because a bare span in a hole is ambiguous between "already framed" and "frame this" - the exact distinction RespFragment and RedisValue exist to keep apart. Also fixes InterpolatedAppendTests.AnAppendAcceptsExactlyWhatTheCommandDoes, which did GetParameters().Single() and broke on the two-parameter overload. --- design/interpolated-resp-writer.md | 35 +++++ src/StackExchange.Redis/Expiration.cs | 30 ++++- .../Interpolated/IRespArgument.cs | 59 ++++++++- .../Interpolated/RespCommandHandler.cs | 120 +++++++----------- .../PublicAPI/PublicAPI.Unshipped.txt | 6 +- src/StackExchange.Redis/ValueCondition.cs | 24 +++- .../InterpolatedAppendTests.cs | 2 +- .../InterpolatedCustomArgTests.cs | 59 ++++++++- 8 files changed, 253 insertions(+), 82 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 7a0ad7229..83695e2a8 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -335,6 +335,41 @@ means "no argument". This is the argument-level counterpart to §9.4: the context is the extension point for *commands*, and `IRespArgument` is the extension point for *argument types*. Without it, `NRedisStack` could add commands but could not add a type that appears in one. + +#### Format specifiers: a second, unrelated interface + +`IRespFormattableArgument.WriteTo(scoped ref RespCommandHandler, string? format)` handles `$"{x:fmt}"`, +behind its own `AppendFormatted(T, string?)` overload. + +It deliberately does **not** derive from `IRespArgument`, so the three combinations are three different +contracts, each enforced by the compiler — measured, both directions: + +| implements | `$"{x}"` | `$"{x:fmt}"` | +|---|---|---| +| `IRespArgument` only | writes | **CS0315**, naming `IRespFormattableArgument` | +| `IRespFormattableArgument` only | **CS0315**, naming `IRespArgument` | writes | +| both | plain form | format form | + +The middle row is the reason for the split rather than a single interface: it makes the format +**mandatory**, which is how a type with no safe default forces the caller to choose — the same move §2.3 +wanted when a bare `$"{ttl}"` would have to guess between `EX` and `PX`. A single interface cannot +express it, and a default interface method cannot fake it: DIMs need runtime support that `net461` and +`netstandard2.0` do not have. + +A type implementing both is unambiguous because the overloads differ in **arity** — the `:` in the hole +decides, not overload betterness, so none of the resolution subtleties above apply. + +*Rough edge:* in the middle row the message reads "no boxing conversion from X to `IRespArgument`", which +is accurate but does not say *"you must supply a format"*. Analyzer candidate. + +#### Alignment: never + +There is no `int alignment` overload and there must not be one. RESP is length-prefixed binary, so +`$"{key,10}"` would pad the payload and send a **different key**, silently — the one failure mode where +the wire bytes change and nothing complains. It is `CS1739` ("does not have a parameter named +'alignment'") today, and pinned by a reflection test asserting no `AppendFormatted` parameter is named +`alignment`, because a compile error cannot be asserted directly and "add it for symmetry with the format +overload" is the plausible way it gets broken. - With no catch-all, `RedisValue`'s existing implicit conversions cover `string`, `int`, `byte[]` etc. for free. diff --git a/src/StackExchange.Redis/Expiration.cs b/src/StackExchange.Redis/Expiration.cs index 67ef4e559..efc6674d2 100644 --- a/src/StackExchange.Redis/Expiration.cs +++ b/src/StackExchange.Redis/Expiration.cs @@ -5,7 +5,7 @@ namespace StackExchange.Redis; /// /// Configures the expiration behaviour of a command. /// -public readonly struct Expiration +public readonly struct Expiration : Interpolated.IRespArgument { /* Redis expiration supports different modes: @@ -309,6 +309,34 @@ internal ReadOnlySpan OperandResp internal ReadOnlySpan ExpireIfNotExistsResp => HasExpirationValue && IsExpireIfNotExists ? "$3\r\nENX\r\n"u8 : default; + /// + /// + /// Explicit, so it does not clutter the type for callers who will never write a RESP frame by hand; + /// reached only through a command hole - $"{...}{expiry}" - which is the one place it means + /// anything. The generic AppendFormatted<T> funnel is what binds it there, in preference + /// to a dedicated overload: if the extension mechanism is good enough for other libraries' types it is + /// good enough for ours, and this is the proof. + /// + void Interpolated.IRespArgument.WriteTo(scoped ref Interpolated.RespCommandHandler handler) + { + var operand = OperandResp; + if (operand.IsEmpty) return; // Expiration.Default contributes no arguments + + // SER011 gates hand-written pre-framed fragments, because nothing validates the claim that the + // bytes are correctly framed. These are compile-time constants owned by this type and shared with + // the MessageWriter path (OperandResp), so the claim is as checked as it can be - and they are + // ALREADY framed, so AppendBulk, which frames what it is given, is not the right primitive. +#pragma warning disable SER011 + handler.AppendFormatted(new Interpolated.RespFragment(operand)); + if (HasExpirationValue) + { + handler.AppendFormatted((RedisValue)Value); + var enx = ExpireIfNotExistsResp; + if (!enx.IsEmpty) handler.AppendFormatted(new Interpolated.RespFragment(enx)); + } +#pragma warning restore SER011 + } + internal void WriteTo(in MessageWriter writer) { var operand = OperandResp; diff --git a/src/StackExchange.Redis/Interpolated/IRespArgument.cs b/src/StackExchange.Redis/Interpolated/IRespArgument.cs index b7586a339..777b0c5b1 100644 --- a/src/StackExchange.Redis/Interpolated/IRespArgument.cs +++ b/src/StackExchange.Redis/Interpolated/IRespArgument.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using RESPite; namespace StackExchange.Redis.Interpolated @@ -23,8 +23,10 @@ namespace StackExchange.Redis.Interpolated /// /// /// Writing nothing is legal and means "no argument", which is how an absent optional argument is - /// spelled; see for the same idea on a - /// built-in type. + /// spelled. and are implemented this way - + /// explicitly - and are what an absent optional argument looks like in practice: an + /// writes nothing, so $"{cmd}{key}{value}{when}{expiry}" + /// covers the whole of SET with no branch. /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] @@ -39,4 +41,55 @@ public interface IRespArgument /// void WriteTo(scoped ref RespCommandHandler handler); } + + /// + /// EXPERIMENTAL SPIKE. Implemented by a type that knows how to write itself as one or more RESP + /// arguments given a format specifier: $"{radius:km}". + /// + /// + /// + /// Deliberately not related to by inheritance, so a type can + /// implement either without the other. That is not tidiness, it is the point - the three combinations + /// are three different contracts, and the compiler enforces whichever one the type declares: + /// + /// + /// + /// only - $"{x}" compiles, $"{x:fmt}" does not: the type has + /// exactly one spelling. + /// + /// + /// This interface only - $"{x:fmt}" compiles, $"{x}" does not: the format is + /// mandatory. That is how a type with no safe default forces the caller to choose, which is the + /// same trick the notes reach for when a bare $"{ttl}" would have to guess between EX and PX. + /// + /// + /// Both - each spelling binds to its own arity, with no ambiguity, and the type decides what an absent + /// format means. + /// + /// + /// + /// A single interface could not express the middle case at all, and a default interface method could + /// not be used to fake it: DIMs need runtime support this library does not have on net461 or + /// netstandard2.0. + /// + /// + /// There is deliberately no alignment counterpart. RESP is length-prefixed binary, so + /// $"{key,10}" would pad the payload and send a different key - silently. It is a + /// compile error today (CS1739, no parameter named 'alignment') and is pinned by a test to keep it + /// that way, because adding it "for symmetry" is the plausible mistake. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public interface IRespFormattableArgument + { + /// Write this value as zero or more RESP arguments, in the requested format. + /// The command being written; append to it via its AppendFormatted methods. + /// + /// The text between the : and the closing brace of the hole. Never null when it arrives from + /// an interpolated string - the compiler passes the literal text, empty at worst - but declared + /// nullable to match the shape the interpolated-string lowering looks for. + /// + /// See for why the parameter is scoped ref. + void WriteTo(scoped ref RespCommandHandler handler, string? format); + } } diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 0f5a248fc..8d7e140f9 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -383,74 +383,6 @@ public void AppendFormatted(RedisChannel value) _argIndex++; } - /// - /// Append an expiration: EX 300, PXAT 1700000000000, KEEPTTL, or - for - /// - nothing at all. - /// - /// The expiration to append. - /// - /// - /// The first argument type that writes a variable number of tokens, including zero. That is - /// the whole reason the optional parts of a command can be written as holes rather than as branches: - /// $"{expiry}{when}" renders to between zero and five arguments and the handler keeps the - /// count straight, where the fixed-arity Message.Create overloads needed a branch per shape. - /// - /// - /// The mode token itself comes from , shared with the - /// MessageWriter path, so the two writers cannot disagree about what an - /// means. - /// - /// - public void AppendFormatted(Expiration value) - { - DemandCommand(); - - var operand = value.OperandResp; - if (operand.IsEmpty) return; // Expiration.Default contributes no arguments - - AppendPreframed(operand); - if (value.HasExpirationValue) - { - AppendFormatted((RedisValue)value.Value); - var enx = value.ExpireIfNotExistsResp; - if (!enx.IsEmpty) AppendPreframed(enx); - } - } - - /// - /// Append a value condition: NX, XX, IFEQ v, IFDNE 0a1b..., or - for - /// - nothing at all. - /// - /// The condition to append. - /// - /// As : variable token count, and the keyword comes from - /// so both writers agree. - /// - public void AppendFormatted(ValueCondition value) - { - DemandCommand(); - - var keyword = value.KeywordResp; - if (keyword.IsEmpty) return; // ValueCondition.Always contributes no arguments - - AppendPreframed(keyword); - if (value.IsValueTest) - { - AppendFormatted(value.Value); - } - else if (value.IsDigestTest) - { - // the wire form is hex of the big-endian digest bytes, NOT the int64 the RedisValue holds - Span hex = stackalloc byte[2 * ValueCondition.DigestBytes]; - var written = ValueCondition.WriteHex(value.Value.OverlappedValueInt64, hex); - var payload = WriteBulk(written.Length, out var payloadOffset); - written.CopyTo(payload); - CommitBulk(payloadOffset, written.Length); - _args++; - _argIndex++; - } - } - /// /// Append any type that knows how to write itself, so the set of things that can appear in a hole /// is open to other assemblies rather than closed to this one. @@ -473,6 +405,11 @@ public void AppendFormatted(ValueCondition value) /// (opting in beats an incidental conversion, which is the wanted answer); and a struct /// implementer is a constrained call, so nothing boxes. /// + /// + /// This is the funnel and arrive through: + /// they had dedicated overloads first, and giving them up is the point - if the mechanism is good + /// enough for other libraries' types, it should be good enough for ours. + /// /// public void AppendFormatted(T value) where T : IRespArgument { @@ -481,12 +418,49 @@ public void AppendFormatted(T value) where T : IRespArgument value.WriteTo(ref this); } - /// Copy one already-framed single-token literal in, advancing the counters by one. - private void AppendPreframed(scoped ReadOnlySpan framed) + /// + /// Append a type that knows how to write itself in a requested format: $"{radius:km}". + /// + /// The argument type; inferred from the hole. + /// The argument to append. + /// The text after the : in the hole. + /// + /// Constrained to and not to + /// , which is what lets a type accept a format without accepting a bare + /// hole, and vice versa; see the remarks on that interface. The two overloads differ in arity, so + /// a type implementing both is unambiguous. + /// + /// There is no int alignment counterpart, and there should never be: padding a + /// length-prefixed binary payload changes what is sent. + /// + /// + public void AppendFormatted(T value, string? format) where T : IRespFormattableArgument + { + DemandCommand(); + if (value is null) throw new ArgumentNullException(nameof(value)); + value.WriteTo(ref this, format); + } + + /// Frame raw bytes as one bulk argument. + /// The payload; framed here, so it must NOT already carry a $len prefix. + /// + /// The primitive an implementation needs when its payload is bytes it + /// computed rather than a it was handed - writing those through a + /// RedisValue would mean allocating a byte[] to carry them. + /// + /// Deliberately a named method and not an AppendFormatted overload: a bare span in a + /// hole is ambiguous between "I already framed this" and "you frame this", which is precisely the + /// distinction and exist to keep apart (see + /// design notes 2.2). Keeping it off the hole vocabulary means the question never arises. + /// + /// + public void AppendBulk(scoped ReadOnlySpan payload) { - Ensure(framed.Length); - framed.CopyTo(_buffer.AsSpan(_offset)); - _offset += framed.Length; + DemandCommand(); + + var target = WriteBulk(payload.Length, out var payloadOffset); + payload.CopyTo(target); + CommitBulk(payloadOffset, payload.Length); _args++; _argIndex++; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2794321cc..3658e30ed 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -46,11 +46,13 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Expiration value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendBulk(scoped System.ReadOnlySpan payload) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value, string? format) -> void [SER010]StackExchange.Redis.Interpolated.IRespArgument [SER010]StackExchange.Redis.Interpolated.IRespArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.ValueCondition value) -> void +[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument +[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler, string? format) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 33abea6e9..047db78a6 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis; /// /// Represents a check for an existing value - this could be existence (NX/XX), equality (IFEQ/IFNE), or digest equality (IFDEQ/IFDNE). /// -public readonly struct ValueCondition +public readonly struct ValueCondition : Interpolated.IRespArgument { internal enum ConditionKind : byte { @@ -300,6 +300,28 @@ static byte ToNibble(int b) _ => default, }; + /// + /// See for why this is an explicit implementation. + void Interpolated.IRespArgument.WriteTo(scoped ref Interpolated.RespCommandHandler handler) + { + var keyword = KeywordResp; + if (keyword.IsEmpty) return; // ValueCondition.Always contributes no arguments + +#pragma warning disable SER011 // pre-framed constants owned by this type; see Expiration for the reasoning + handler.AppendFormatted(new Interpolated.RespFragment(keyword)); +#pragma warning restore SER011 + if (IsValueTest) + { + handler.AppendFormatted(_value); + } + else if (IsDigestTest) + { + // the wire form is hex of the big-endian digest bytes, NOT the int64 the RedisValue holds; + // AppendBulk takes the stack buffer directly, where a RedisValue would need a byte[] + handler.AppendBulk(WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes])); + } + } + internal void WriteTo(in MessageWriter writer) { var keyword = KeywordResp; diff --git a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs index d0b4a358f..9da0fd193 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs @@ -33,7 +33,7 @@ public void AnAppendAcceptsExactlyWhatTheCommandDoes() var accepted = typeof(RespCommandHandler) .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.Name == "AppendFormatted") - .Select(m => m.GetParameters().Single().ParameterType.Name) + .Select(m => m.GetParameters()[0].ParameterType.Name) // [0] is the value; a format may follow .ToArray(); Assert.Contains("RedisKey", accepted); diff --git a/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs index 750a9266d..50b2fe110 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs @@ -1,4 +1,6 @@ -using System; +using System; +using System.Linq; +using System.Reflection; using System.Text; using StackExchange.Redis.Interpolated; using Xunit; @@ -43,6 +45,61 @@ public void WriteTo(scoped ref RespCommandHandler handler) { } public void WriteTo(scoped ref RespCommandHandler handler) => handler.AppendFormatted((RedisValue)"INTERFACE"); } + /// Format ONLY: a bare $"{x}" must not compile, because there is no safe default. + private readonly struct Radius(double distance) : IRespFormattableArgument + { + public void WriteTo(scoped ref RespCommandHandler handler, string? format) + { + handler.AppendFormatted((RedisValue)distance); + handler.AppendFormatted((RedisValue)(format ?? "m")); + } + } + + /// Both, so each spelling has somewhere to go and we can see which one it picked. + private readonly struct Either : IRespArgument, IRespFormattableArgument + { + public void WriteTo(scoped ref RespCommandHandler handler) + => handler.AppendFormatted((RedisValue)"PLAIN"); + + public void WriteTo(scoped ref RespCommandHandler handler, string? format) + => handler.AppendFormatted((RedisValue)("FORMAT:" + format)); + } + + [Fact] + public void AFormatSpecifierReachesTheImplementerVerbatim() + { + using var frame = Ctx.Execute($"{RedisCommand.GEOSEARCH}{(RedisKey)"k"}{new Radius(5):km}"); + Assert.Equal("*4|$9|GEOSEARCH|$1|k|$1|5|$2|km|", Text(frame)); + } + + [Fact] + public void EachSpellingBindsToItsOwnArity() + { + // a type implementing both is unambiguous: the overloads differ in arity, so the presence or + // absence of the `:` in the hole decides, not overload betterness + using var plain = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Either()}"); + Assert.Equal("*3|$3|GET|$1|k|$5|PLAIN|", Text(plain)); + + using var formatted = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Either():xyz}"); + Assert.Equal("*3|$3|GET|$1|k|$10|FORMAT:xyz|", Text(formatted)); + } + + [Fact] + public void ThereIsNoAlignmentOverloadAndThereShouldNeverBe() + { + // RESP is length-prefixed binary: `$"{key,10}"` would pad the payload and send a DIFFERENT key, + // silently. It is CS1739 today, and the plausible way that breaks is someone adding the overload + // "for symmetry" with the format one - which this catches, where a compile error cannot be tested. + var offenders = typeof(RespCommandHandler) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.Name == "AppendFormatted") + .SelectMany(m => m.GetParameters()) + .Where(p => p.Name == "alignment") + .ToArray(); + + Assert.Empty(offenders); + } + [Fact] public void ACustomTypeCanAppearInAHole() { From 43ed219f012c13c88d50d4fdac2b40c6dc54052f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:10:43 +0100 Subject: [PATCH 084/360] Re-verify that extension AppendFormatted cannot bind in a hole The claim is load-bearing for the layering question, so it was re-tested on the current compiler against the real handler, and the result is stronger than what was recorded: it is not that an instance member wins, it is that extension lookup never runs at all. The decisive case: a handler whose ONLY member is AppendFormatted(int), plus an extension taking Geo. `$"{new Geo()}"` gives CS1503 "cannot convert from 'Geo' to 'int'" - no instance member was applicable, and the compiler still bound to one and failed the conversion rather than consulting the extension. Ordinary C# would have used the extension there. Positive control: those same extension methods compile and RUN as ordinary calls in the same file with the same usings, so they are genuinely in scope. Both the classic `this ref` form and the C# 14 extension block form. Consequence, now recorded: a RedisCommand hole can only be served by an instance member of the handler type, and RedisCommand is an enum so it cannot implement IRespArgument either. The assembly declaring the handler must therefore know about RedisCommand - so the handler cannot simply move to RESPite. The split has to be by layer (a RESPite writer held by value inside the SE.Redis handler), which is verified to compile. --- design/interpolated-resp-writer.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 83695e2a8..d84845bb2 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -289,9 +289,32 @@ public static void AppendFormatted(this H h, Vec v) // no extension(ref H h) { public void AppendFormatted(Blob v) } // no (C# 14 extension block) ``` -The lowering does member lookup against instance members declared on the handler type and stops. -So nobody — not a consumer, not another assembly here — can extend it after the fact **by adding a -method**. `IRespArgument` (below) is the sanctioned way back in. +Re-verified on the current compiler against the real `RespCommandHandler`, and the third case below is +the one that settles it — it is not "an instance member wins", it is that **extension lookup never runs**: + +| setup | `$"{x}"` | +|---|---| +| classic `this ref` extension, other instance members present | CS0315 against the *instance* generic | +| C# 14 `extension(ref H h)` block | CS0315 against the *instance* generic | +| handler whose only member is `AppendFormatted(int)`, extension takes `Geo` | **CS1503, "cannot convert from 'Geo' to 'int'"** | + +The third row is the proof. Ordinary C# consults extensions when no instance method is applicable; here +none was applicable and the compiler still bound to the instance member and failed the conversion. (That +is also where the "CS1503 names an arbitrary overload" cost below comes from.) + +**Positive control:** the very same extension methods, in the same file with the same usings, compile and +*run* as ordinary calls — `cmd.AppendFormatted(new Geo())`. So they are genuinely in scope and valid; the +lowering simply does not look at them. + +So nobody — not a consumer, not another assembly here — can extend the hole vocabulary after the fact +**by adding a method**. `IRespArgument` (below) is the sanctioned way back in, and being an interface on +the *argument* rather than a method on the *handler* is exactly why it works. + +**Consequence for layering (§9.5):** a `RedisCommand` hole can only ever be served by an instance member +of the handler type. `RedisCommand` is an `enum`, so it cannot implement `IRespArgument` either. Whatever +assembly declares the handler type must therefore know about `RedisCommand` — which is why the handler +cannot simply move to RESPite, and why the split is by *layer* (a RESPite `RespWriter` held by value +inside the SE.Redis handler) rather than by relocation. Consequences: From 24ada5ad15d79104d2132f5200b0e629c996fd98 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:11:57 +0100 Subject: [PATCH 085/360] Record the layering decision: stays in SE.Redis Section 9.5, and fixes what was a dangling forward reference to it. Captures the decision and the reason it is not merely "later": a RedisCommand hole can only be served by an instance member of the handler type, and RedisCommand is an enum so IRespArgument is closed to it too - so whatever assembly declares the handler must know about RedisCommand. A key is bytes and hands down a layer cleanly; a command is a lookup and drags CommandMap policy with it. Also records the shape that WOULD work when it is worth doing - a RESPite RespWriter held by value inside the SE.Redis handler, which is verified to compile and run - along with the two things that move with it (AppendKey needs a two-span prefix/body form; FoldSlot's CRC16 belongs a layer down), and the rejected "never put a command in a hole" alternative. --- design/interpolated-resp-writer.md | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index d84845bb2..d53a0c0bb 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2503,6 +2503,52 @@ break" is spent once at the interface rather than again at the entry point. deliberately avoids and which a large constituency depends on. Fine for a spike; but keeping the sync member means a real sync path can arrive later without reshaping the API, and it costs nothing now. +### 9.5 Layering: why this stays in SE.Redis for now + +**Decision: it stays. Not moving `RespCommandHandler` to RESPite.** + +The prize would be real — RESPite already owns `RespReader`, and a matching writer would let anything +build RESP commands with pooled buffers, key marks and slot folding, with no Redis semantics attached. +The route looked available too: give the writer an `AppendKey(ReadOnlySpan)` primitive, let +`RedisKey` reach it through `IRespArgument`, and key reporting flows down a layer while `RedisKey` stays +up here. + +**What stops it is the command, not the key.** A `RedisCommand` hole can only ever be served by an +instance member of the handler type (§2.2 — extension lookup never runs for the handler pattern, and the +CS1503 case proves it), and `RedisCommand` is an `enum`, so `IRespArgument` is closed to it as well. +Whatever assembly declares the handler must therefore know about `RedisCommand`. `RespCommand` does not +rescue it either: it *holds* a `RedisCommand`, and resolves through `RespContext.ResolveCommand`, i.e. +`CommandMap` — renames, disabled commands, per-server-type maps. That is policy, not protocol. + +A key is *bytes*; a command is *a lookup*. Bytes hand down a layer cleanly. A lookup drags its policy +with it. + +**The shape that would work, when it is worth doing:** split the type, do not relocate it. RESPite owns a +`RespWriter` — buffer rental, bulk framing, the `*N` back-fill, argument counters, key marks, slot folding +— exposing primitives only (`AppendBulk`, `AppendKey(prefix, body)`, a pre-framed form, `Complete`). +SE.Redis keeps `RespCommandHandler` as the `[InterpolatedStringHandler]`, holding a `RespWriter` **by +value** and owning the whole hole vocabulary. Verified to compile and run: a `ref struct` may contain +another `ref struct` by value, and the outer type's `AppendFormatted` members bind normally while +delegating the writing inward. (CS9050 bars a ref *field* to a ref struct; by-value containment is fine.) + +Two things move with it whenever that happens: + +- `AppendKey` needs a **two-span** form, `(prefix, body)`. Today the context prefix and any prefix the key + already carries from a `KeyPrefixed*` decorator are written straight into the frame rather than + concatenated, specifically to avoid the allocation `RedisKey.WithPrefix` would cost. +- `FoldSlot` calls `ServerSelectionStrategy.GetClusterSlot` — CRC16 over the key bytes. Standard Redis + Cluster, so it belongs in the lower layer anyway. + +**Why not now:** it is a pure refactor with no behavioural change, across a spike that is still growing; +moving files today churns everything in flight for nothing. Revisit when the surface stops moving, or +when something outside this repo actually wants to write RESP commands — whichever comes first. + +**Rejected along the way:** "never put a command in a hole, always `Compose(RedisCommand.SET, $"...")`". +That form is already preferred (§6.5 — resolution happens before the buffer is rented, so a disabled +command drops nothing on the floor), but it does not rescue the move: `COMMAND INFO `, +`COMMAND DOCS` and `ACL` rules need a command *as an argument*, which is a hole by definition. + + ## 10. Open questions - **Should a `RedisChannel` fold into the same slot as keys?** The spike folds it unconditionally, which From f68263ac81cae34c7abfb31ceb288669a080f15f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:13:55 +0100 Subject: [PATCH 086/360] Tighten the shared operand helpers to private OperandResp, HasExpirationValue, ExpireIfNotExistsResp and KeywordResp were internal so both writers could share them. Now that each type writes both forms itself - the MessageWriter path and the explicit IRespArgument one - every caller is inside the declaring file, so none of them needs to be internal. The only test references were in a comment. IsValueTest/IsDigestTest stay internal: DigestUnitTests asserts on them. Sharing across two writers no longer costs any visibility at all; what keeps the two honest is the test that renders the same command through both and compares bytes. --- src/StackExchange.Redis/Expiration.cs | 12 +++++++----- src/StackExchange.Redis/ValueCondition.cs | 8 ++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/StackExchange.Redis/Expiration.cs b/src/StackExchange.Redis/Expiration.cs index efc6674d2..d0600ebbf 100644 --- a/src/StackExchange.Redis/Expiration.cs +++ b/src/StackExchange.Redis/Expiration.cs @@ -280,11 +280,13 @@ internal int GetTokenCount(bool allowEnx) /// empty when this expiration contributes no arguments at all. /// /// - /// Shared by every writer rather than restated per writer: this switch is the whole of the mode - /// selection, and it is the part that would silently diverge if each writer kept its own copy. + /// Shared by both writers - the MessageWriter path and the interpolated one - rather than + /// restated in each: this switch is the whole of the mode selection, and it is the part that would + /// silently diverge if either kept its own copy. Both live on this type, so it is private; + /// what keeps them honest is a test that renders the same command through both and compares bytes. /// says whether a numeric operand follows it. /// - internal ReadOnlySpan OperandResp + private ReadOnlySpan OperandResp { get { @@ -303,10 +305,10 @@ internal ReadOnlySpan OperandResp /// Whether is followed by a numeric . /// False for KEEPTTL and PERSIST, which are complete in themselves. - internal bool HasExpirationValue => (_flags & ExpirationState.HasExpiration) != 0; + private bool HasExpirationValue => (_flags & ExpirationState.HasExpiration) != 0; /// The already-framed RESP token for ENX, or empty when it does not apply. - internal ReadOnlySpan ExpireIfNotExistsResp + private ReadOnlySpan ExpireIfNotExistsResp => HasExpirationValue && IsExpireIfNotExists ? "$3\r\nENX\r\n"u8 : default; /// diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 047db78a6..471316e0f 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -285,11 +285,11 @@ static byte ToNibble(int b) /// the condition contributes no arguments. /// /// - /// Shared by every writer rather than restated per writer; see the same note on - /// . and say - /// what follows it, and in which encoding. + /// Shared by both writers rather than restated in each; see the equivalent on . + /// and say what follows it, and in which encoding - + /// those two stay internal because DigestUnitTests asserts on them; this does not. /// - internal ReadOnlySpan KeywordResp => _kind switch + private ReadOnlySpan KeywordResp => _kind switch { ConditionKind.Exists => "$2\r\nXX\r\n"u8, ConditionKind.NotExists => "$2\r\nNX\r\n"u8, From d7a4ed8a492de5da0cc8afe78f7565e772a157bc Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:36:53 +0100 Subject: [PATCH 087/360] TransitionalDatabase: IDatabase over the context surface [AutoDatabase] already generated the whole of IDatabase/IDatabaseAsync for decorators (MultiGroupDatabase implements the lot in 96 lines), so there is no hand-written NIE file at all - what was missing was one feature. Generator: skip members the class implements itself. That is a correctness fix, not a convenience. The generator emits EXPLICIT interface implementations, so a hand-written public member does not collide with a generated one - both compile, and interface dispatch quietly prefers the generated throw. Now the generated set is by construction "everything not in the implemented partial". (Existing users are unaffected: they hand-write only members SkipMethod already skipped by category.) TransitionalDatabase itself is ~100 lines plus the streaming scans, which [AutoDatabase] skips by category and MultiGroupDatabase hand-writes too. Its funnels throw rather than forward, because there is no inner IDatabase - that is the point. StringGet/StringSet and their async twins are real, including the legacy TimeSpan?/When overloads most callers actually bind to. Sync is deprioritised by decision, so the bridge is the cheap version: take a synchronously-completed result directly (a cache hit costs nothing) and otherwise block via the multiplexer's timeout. That is sync-over-async and is commented as such, with the routing fix that would remove it - the executor already has a synchronous Send. SER352 is the tripwire: on Release builds an [AutoDatabase(WarnIfIncomplete = true)] type reports how many members still only throw. Release only, so the inner loop stays quiet, and opt-in, because the other users' generated members genuinely work. It currently reports 618, and the count falls by itself as commands move across. TreatWarningsAsErrors would make that fatal and block the work it tracks, so the library sets WarningsNotAsErrors for it, with a comment saying that deleting the line is what makes shipping an incomplete type impossible. Worth knowing: removing the skip is caught at build time by SA1648, because the hand-written members carry and stop implementing anything. That is luck rather than design, so the behaviour is pinned by a test that calls through IDatabase. --- docs/rules/SER352.md | 35 ++++++ docs/rules/index.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 + .../AutoDatabaseGenerator.cs | 95 ++++++++++++++++- eng/StackExchange.Redis.Build/Diagnostics.cs | 27 +++++ src/StackExchange.Redis/AutoDatabase.cs | 13 ++- .../TransitionalDatabase.Implemented.cs | 97 +++++++++++++++++ .../TransitionalDatabase.Scans.cs | 53 ++++++++++ .../Interpolated/TransitionalDatabase.cs | 100 ++++++++++++++++++ .../StackExchange.Redis.csproj | 9 +- .../TransitionalDatabaseTests.cs | 98 +++++++++++++++++ 11 files changed, 522 insertions(+), 7 deletions(-) create mode 100644 docs/rules/SER352.md create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs create mode 100644 tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs diff --git a/docs/rules/SER352.md b/docs/rules/SER352.md new file mode 100644 index 000000000..b4c0dd7be --- /dev/null +++ b/docs/rules/SER352.md @@ -0,0 +1,35 @@ +# SER352: generated database members are not implemented + +A type marked `[AutoDatabase(WarnIfIncomplete = true)]` still has members that the generator supplied, and +those members throw rather than doing anything. The count is how many. + +This is a **transition tripwire**, not a code-quality rule. `[AutoDatabase]` fills in every member of +`IDatabase`/`IDatabaseAsync` that the type does not implement itself, funnelling them through the type's own +`Execute`/`ExecuteAsync`. For most users of the attribute those generated members genuinely work - they +forward to an inner database - and this rule is off for them. It is opt-in precisely because "generated" and +"unimplemented" are only the same thing for a type whose funnel throws, which is the shape of a type being +migrated to a new implementation one command at a time. + +## Fixing it + +Implement the member on the type. That is the whole fix, and it is self-maintaining: the generator skips +whatever the class declares itself, so the member stops being generated and the count goes down. When the +count reaches zero the warning disappears on its own. + +There is nothing to add to a list, and no list to forget to update - which is the reason the generated half +exists at all. + +## Why Release only + +The inner development loop is exactly when the type is *expected* to be incomplete, and a warning that fires +on every build during the work it is describing is a warning people learn to skip past. It is reported when +the `DEBUG` preprocessor symbol is absent, which is how the SDK's own `Release` configuration is defined. + +## Suppressing + +Do not suppress it to make it quiet - it is counting something real, and something that will throw in front +of a user. + +Do consider `SER352` while the migration is in progress: in a +build with `TreatWarningsAsErrors` this rule is otherwise fatal, which blocks the very work it is tracking. +Removing that line once the migration is close is what makes shipping an incomplete type impossible. diff --git a/docs/rules/index.md b/docs/rules/index.md index b85d0133e..d4798942a 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -50,6 +50,7 @@ Unlike everything under [Usage](#usage), these describe code that does not do wh - [SER350](SER350) - language version too low for generated code - [SER351](SER351) - a `[Resp]` declaration the fragment generator cannot implement +- [SER352](SER352) - generated database members that are not implemented and will throw ## When these rules stay quiet diff --git a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md index a739fdd99..74832c56a 100644 --- a/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md +++ b/eng/StackExchange.Redis.Build/AnalyzerReleases.Unshipped.md @@ -17,3 +17,4 @@ SER307 | Usage | Warning | QueuedResultAnalyzer: blocking on a redis call i SER308 | Usage | Warning | QueuedResultAnalyzer: calling the library's own Wait/WaitAll/TryWait helpers, which block the calling thread SER309 | Usage | Warning | RespInterpolationAnalyzer: literal text in a RESP interpolated command is parsed and encoded on every call, where a fragment or resolved command is prepared once SER351 | Build | Warning | RespFragmentGenerator: a [Resp] declaration that cannot be implemented, which would otherwise be skipped silently +SER352 | Build | Warning | AutoDatabaseGenerator: an [AutoDatabase(WarnIfIncomplete = true)] type still has generated members that only throw; Release builds only diff --git a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs index f40cfd8c4..b889793d7 100644 --- a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs +++ b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Immutable; using System.Reflection.Metadata.Ecma335; using System.Text; @@ -9,7 +10,7 @@ using ParamInfo = (string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default); using MethodInfo = (string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs); using InterfaceInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces KnownType, StackExchange.Redis.Build.BasicArray<(string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs)> Methods); -using ClassInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces Interfaces, bool IsMutator, bool Replays); +using ClassInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces Interfaces, bool IsMutator, bool Replays, StackExchange.Redis.Build.BasicArray Declared, bool WarnIfIncomplete); namespace StackExchange.Redis.Build; @@ -30,7 +31,14 @@ public void Initialize(IncrementalGeneratorInitializationContext ctx) .Where(pair => pair.Name is { Length: > 0 }) .Collect(); - ctx.RegisterSourceOutput(interfaces.Combine(classes), static (ctx, content) => Generate(ctx, content.Left, content.Right)); + // Release is "no DEBUG symbol", which is what the SDK's own configuration does; a generator has no + // other reliable view of $(Configuration) without the project opting the property in. + var isRelease = ctx.ParseOptionsProvider.Select( + static (options, _) => !options.PreprocessorSymbolNames.Contains("DEBUG")); + + ctx.RegisterSourceOutput( + interfaces.Combine(classes).Combine(isRelease), + static (ctx, content) => Generate(ctx, content.Left.Left, content.Left.Right, content.Right)); } /// @@ -221,18 +229,77 @@ static bool HasAutoDatabaseAttrib(INamedTypeSymbol symbol) // [AutoDatabase(Replays = true)] - the owning database can invoke a captured operation more than // once, so captured Memory arguments have to be copies rather than the caller's own buffer - bool replays = false; + bool replays = false, warnIfIncomplete = false; foreach (var attrib in cls.GetAttributes()) { if (attrib.AttributeClass?.Name is not ("AutoDatabaseAttribute" or "AutoDatabase")) continue; foreach (var named in attrib.NamedArguments) { if (named.Key == "Replays" && named.Value.Value is bool b) replays = b; + if (named.Key == "WarnIfIncomplete" && named.Value.Value is bool w) warnIfIncomplete = w; } } + // What the class already implements by hand, so generation can stand back. The generator emits + // EXPLICIT interface implementations, which means a hand-written member does NOT collide - both + // compile, and interface dispatch silently prefers the generated one. So this is a correctness + // check, not a convenience: without it, a partial that implements StringGet for real would still + // see IDatabase.StringGet route to the funnel. + // + // Other partials are ordinary source and therefore visible here; this generator's own output is + // not part of the compilation it sees, so there is nothing to exclude. + var declared = new List(); + foreach (var member in cls.GetMembers()) + { + cancel.ThrowIfCancellationRequested(); + if (member is not IMethodSymbol { MethodKind: MethodKind.Ordinary or MethodKind.ExplicitInterfaceImplementation } method) continue; + declared.Add(SignatureKey(method)); + } + var ns = cls.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - return (cls.Name, ns, known, isMutator, replays); + return (cls.Name, ns, known, isMutator, replays, BasicArray.From(declared), warnIfIncomplete); + } + + /// + /// Name plus parameter types, formatted exactly as the interface side formats them so the two can be + /// compared as strings. + /// + /// + /// An explicit implementation's Name is the fully-qualified Namespace.IFace.Member, so + /// the implemented member's own name is used instead - a hand-written explicit implementation and a + /// hand-written public one mean the same thing here: "do not generate this". + /// + private static string SignatureKey(IMethodSymbol method) + { + var name = method.ExplicitInterfaceImplementations.Length > 0 + ? method.ExplicitInterfaceImplementations[0].Name + : method.Name; + + var sb = new StringBuilder(name).Append('('); + bool first = true; + foreach (var p in method.Parameters) + { + if (!first) sb.Append(','); + first = false; + sb.Append(p.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)); + } + + return sb.Append(')').ToString(); + } + + /// The same key, from the interface-side shape. + private static string SignatureKey(MethodInfo method) + { + var sb = new StringBuilder(method.Name).Append('('); + bool first = true; + foreach (var p in method.Parameters.Span) + { + if (!first) sb.Append(','); + first = false; + sb.Append(p.Type); + } + + return sb.Append(')').ToString(); } [Flags] @@ -302,7 +369,7 @@ private static string StripTask(string returnType) _ => value.ToString() ?? "null", }; - private static void Generate(SourceProductionContext ctx, ImmutableArray interfaces, ImmutableArray classes) + private static void Generate(SourceProductionContext ctx, ImmutableArray interfaces, ImmutableArray classes, bool isRelease) { if (interfaces.IsDefaultOrEmpty | classes.IsDefaultOrEmpty) return; // nothing to do @@ -330,6 +397,13 @@ private static void Generate(SourceProductionContext ctx, ImmutableArray(StringComparer.Ordinal); + foreach (var signature in cls.Declared) declared.Add(signature); + + // how many members this class did NOT implement, for the SER352 tripwire below + int generated = 0; + // unique parameter-type signatures encountered while emitting this class's methods; // keyed on the '|'-joined parameter types so distinct methods with the same shape share // one state struct. tupleDefs[i] holds a representative parameter list for _tuple{i}. @@ -354,6 +428,15 @@ private static void Generate(SourceProductionContext ctx, ImmutableArray 0) + { + var fqn = string.IsNullOrWhiteSpace(cls.Namespace) ? cls.Name : cls.Namespace + "." + cls.Name; + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.AutoDatabaseIncomplete, Location.None, generated, fqn)); + } + writer.Outdent().NewLine().Append("}"); if (!string.IsNullOrWhiteSpace(cls.Namespace)) @@ -377,6 +460,8 @@ void AppendInterfaceMethods(KnownInterfaces knownType) foreach (var method in iType.Methods) { if (SkipMethod(method)) continue; // wonky by nature - left for the caller to implement manually + if (declared.Contains(SignatureKey(method))) continue; // the class implements this itself + generated++; writer.NewLine().Append(method.ReturnType).Append(" global::") .Append(iType.Namespace).Append('.').Append(iType.Name).Append('.').Append(method.Name).Append("("); diff --git a/eng/StackExchange.Redis.Build/Diagnostics.cs b/eng/StackExchange.Redis.Build/Diagnostics.cs index 009948af7..ebc4562f4 100644 --- a/eng/StackExchange.Redis.Build/Diagnostics.cs +++ b/eng/StackExchange.Redis.Build/Diagnostics.cs @@ -354,5 +354,32 @@ internal static class Diagnostics description: "The RespFragment generator implements [Resp] partial properties of type RespFragment; a declaration it cannot match is skipped, which would otherwise appear only as a missing implementation part.", helpLinkUri: HelpLink("SER351")); + /// + /// An [AutoDatabase(WarnIfIncomplete = true)] type still has members that only throw. + /// + /// + /// + /// A transition tripwire, not a code-quality rule. A type being migrated to a new implementation one + /// command at a time has the rest generated as throwing stubs; that is fine while the work is in + /// progress and not fine in something that ships, and the difference is invisible at a glance + /// because the whole point of the generated half is that nobody writes or reads it. + /// + /// + /// Reported only for Release builds (detected by the absence of the DEBUG preprocessor + /// symbol) so the inner development loop stays quiet, and only where the attribute opts in - the other + /// [AutoDatabase] users generate members that genuinely work by forwarding, and counting those + /// as "not implemented" would be both wrong and deafening. + /// + /// + public static readonly DiagnosticDescriptor AutoDatabaseIncomplete = new( + id: "SER352", + title: "Generated database members are not implemented", + messageFormat: "{0} member(s) of '{1}' are not implemented and will throw at run time", + category: BuildCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A type generated by [AutoDatabase(WarnIfIncomplete = true)] still has members that only throw; it is mid-transition and should not ship in that state.", + helpLinkUri: HelpLink("SER352")); + private static string HelpLink(string id) => string.Format(HelpLinkFormat, id); } diff --git a/src/StackExchange.Redis/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs index 7f2674c44..c4ccd6b3c 100644 --- a/src/StackExchange.Redis/AutoDatabase.cs +++ b/src/StackExchange.Redis/AutoDatabase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -11,6 +11,17 @@ namespace StackExchange.Redis; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false)] internal sealed class AutoDatabaseAttribute : Attribute { + /// + /// Warn (SER352) on Release builds while any member of the decorated type is still generated + /// rather than implemented by the type itself. + /// + /// + /// For a type that is mid-transition, where the generated members throw rather than forward. Off by + /// default, because the other users of this attribute generate members that genuinely work - counting + /// those as unimplemented would be both wrong and deafening. + /// + public bool WarnIfIncomplete { get; set; } + /// /// Whether the owning database can invoke a captured operation more than once, i.e. it replays. /// diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs new file mode 100644 index 000000000..4def91dfd --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs @@ -0,0 +1,97 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The commands that have actually moved to the RESP context surface. Everything else is generated by + /// [AutoDatabase] and throws; see TransitionalDatabase.cs. + /// + /// + /// + /// This is the file that grows as the transition proceeds, and the only one that needs touching to + /// move a command across: implementing a member here removes it from the generated set automatically, + /// because the generator skips what the class implements itself. + /// + /// + /// Note how little is here per command. The body is the whole implementation - no message type, no + /// result processor, no overload ladder - because the command already exists on the context surface + /// and this is purely the adapter from the old interface to it. + /// + /// + internal sealed partial class TransitionalDatabase + { + // ---- strings ------------------------------------------------------------------------------------ + + /// + public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Get(key, flags)); + + /// + public Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.Get(key, flags).AsTask(); + + /// + public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Set(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags)); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Context.Strings.Set(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags).AsTask(); + + // the two legacy When/TimeSpan? shapes, which is what most existing callers actually bind to. + // `When` converts implicitly to ValueCondition, and CreateOrKeepTtl is the same helper + // RedisDatabase uses, so these are pure adapters with no second opinion about semantics. + + /// + public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when) + => StringSet(key, value, expiry, keepTtl: false, when); + + /// + public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when, CommandFlags flags) + => StringSet(key, value, expiry, keepTtl: false, when, flags); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when) + => StringSetAsync(key, value, expiry, keepTtl: false, when); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when, CommandFlags flags) + => StringSetAsync(key, value, expiry, keepTtl: false, when, flags); + + /// + public bool StringSet(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Set(key, value, expiry, when, flags)); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => Context.Strings.Set(key, value, expiry, when, flags).AsTask(); + + // ---- the sync bridge ---------------------------------------------------------------------------- + + /// Block for an asynchronous result, applying the multiplexer's timeout. + /// + /// + /// Sync is deliberately deprioritised, so this is the cheap version rather than the right + /// one. A synchronously-completed result - notably a client-side cache hit - is taken directly and + /// costs nothing. Anything else blocks on a Task, which is sync-over-async: the async path + /// completes its task sources with RunContinuationsAsynchronously (see + /// ResultBox.cs), so the completion needs a thread-pool thread while this one is blocked + /// holding another. That is the failure mode SER307/SER308 exist to warn about. + /// + /// + /// The proper fix is routing rather than waiting: IRespExecutor already has a synchronous + /// Send, and RespExecutor.Send already uses it, so a context flag consulted by the + /// one shared funnel would make every sync call complete inline and reduce this method to its fast + /// path. Worth doing when sync stops being deprioritised - not before. + /// + /// + private T Wait(ValueTask pending) + { + if (pending.IsCompletedSuccessfully) return pending.Result; + + #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers + return multiplexer.Wait(pending.AsTask()); + #pragma warning restore SER308 + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs new file mode 100644 index 000000000..a2e5bfd34 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace StackExchange.Redis.Interpolated; + +// Streaming cursor scans (IEnumerable / IAsyncEnumerable) are deferred-execution and do not fit the +// capture-and-replay shape, so [AutoDatabase] skips them by category - they are the one part of the +// interface that does have to be listed by hand, and the only part of this type that a new command could +// oblige someone to touch. +internal sealed partial class TransitionalDatabase +{ + private static NotImplementedException NotMoved() + => new("This command has not yet moved to the RESP context surface."); + + public IEnumerable HashScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => throw NotMoved(); + + public IEnumerable HashScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IEnumerable SetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => throw NotMoved(); + + public IEnumerable SetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => throw NotMoved(); + + public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IEnumerable VectorSetRangeEnumerate(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IAsyncEnumerable HashScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IAsyncEnumerable SetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IAsyncEnumerable SortedSetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); + + public IAsyncEnumerable VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => throw NotMoved(); +} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs new file mode 100644 index 000000000..364304cba --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. An backed by the new context surface, one command at a + /// time. + /// + /// + /// + /// The transition vehicle: eventually GetDatabase() returns one of these (under some less + /// provisional name), so the existing interface keeps working while the commands behind it move to the + /// new write path one at a time. Anything not yet moved throws , + /// which is loud, local, and impossible to mistake for working. + /// + /// + /// The unimplemented members are not written here - they are generated. [AutoDatabase] + /// emits every member of / that this class does not + /// implement itself, funnelled through Execute/ExecuteAsync below. So this file does not + /// grow a line when a command is added to the interface, and cannot drift out of step with it: the + /// generated set is *by construction* "everything not in + /// TransitionalDatabase.Implemented.cs". A hand-written NIE file would need a line per command + /// and would silently miss new ones. + /// + /// + /// That skip-what-is-implemented behaviour is new, and it is a correctness fix rather than a + /// convenience: the generator emits explicit interface implementations, so a hand-written member + /// does not collide with a generated one - both compile, and interface dispatch quietly prefers the + /// generated throw. + /// + /// + [AutoDatabase(WarnIfIncomplete = true)] + internal sealed partial class TransitionalDatabase(RespDatabase inner, IConnectionMultiplexer multiplexer, object? asyncState) + : IDatabase + { + private readonly RespDatabase _inner = inner; + + /// + public RespContext Context => _inner.Context; + + /// The async state carried by tasks this database produces. + public object? AsyncState => asyncState; + + /// + public int Database => Context.Database; + + /// + public IConnectionMultiplexer Multiplexer => multiplexer; + + // ---- [AutoDatabase] funnels --------------------------------------------------------------------- + // Every member this class does not implement lands here. There is no inner IDatabase to replay + // against - that is the whole point - so the captured state is never invoked, and the throw names + // the member so the message says which command still needs moving. + private TResult Execute(in TState state, AutoDatabaseSyncOperation operation) + where TState : struct + => throw NotMoved(); + + private void Execute(in TState state, AutoDatabaseSyncOperation operation) + where TState : struct + => throw NotMoved(); + + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) + where TState : struct + => throw NotMoved(); + + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) + where TState : struct + => throw NotMoved(); + + private static NotImplementedException NotMoved() + => new($"This command has not yet moved to the RESP context surface (captured as '{typeof(TState).Name}')."); + + // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- + public IBatch CreateBatch(object? asyncState = null) => throw new NotImplementedException(); + + public ITransaction CreateTransaction(object? asyncState = null) => throw new NotImplementedException(); + + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); + + public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) => throw new NotImplementedException(); + + public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => throw new NotImplementedException(); + + public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => throw new NotImplementedException(); + + // the Wait family operates on caller-supplied Tasks, not server calls + #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers + public bool TryWait(Task task) => task.Wait(multiplexer.TimeoutMilliseconds); + + public void Wait(Task task) => multiplexer.Wait(task); + + public T Wait(Task task) => multiplexer.Wait(task); + + public void WaitAll(params Task[] tasks) => multiplexer.WaitAll(tasks); + #pragma warning restore SER308 + } +} diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 5199d9251..be4f00b76 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -15,7 +15,14 @@ - + + + $(WarningsNotAsErrors);SER352 + diff --git a/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs new file mode 100644 index 000000000..9903c0af1 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// TransitionalDatabase: the old surface over the new context surface, one +/// command at a time. +/// +public class TransitionalDatabaseTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + // the multiplexer is only reached to apply a timeout when a send does NOT complete synchronously; + // the fake always does, so passing null here also pins that the fast path really is the fast path + private static IDatabase Target(FakeExecutor executor) + => new TransitionalDatabase(new RespDatabase(new RespContext().WithExecutor(executor)), null!, null); + + [Fact] + public void AMovedCommandReachesTheContextSurface() + { + // THE test for the generator change. Both a hand-written public member and a generated EXPLICIT + // one compile happily side by side, and interface dispatch silently prefers the generated throw - + // so nothing here can be caught at build time. Going through IDatabase is what proves the + // generator stood back. + var executor = new FakeExecutor("$4\r\nmarc\r\n"); + var db = Target(executor); + + Assert.Equal("marc", (string?)db.StringGet("user:1")); + Assert.Equal("*2|$3|GET|$6|user:1|", Assert.Single(executor.Sent)); + } + + [Fact] + public async Task AMovedCommandWorksAsynchronouslyToo() + { + var executor = new FakeExecutor("$4\r\nmarc\r\n"); + var db = Target(executor); + + Assert.Equal("marc", (string?)await db.StringGetAsync("user:1")); + } + + [Fact] + public void TheFullSetOverloadRendersItsOptionalArguments() + { + var executor = new FakeExecutor("+OK\r\n"); + var db = Target(executor); + + Assert.True(db.StringSet("k", "v", TimeSpan.FromSeconds(300), when: When.NotExists)); + Assert.Equal("*6|$3|SET|$1|k|$1|v|$2|NX|$2|EX|$3|300|", Assert.Single(executor.Sent)); + } + + [Fact] + public void AnUnmovedCommandThrowsAndSaysSo() + { + var db = Target(new FakeExecutor("+OK\r\n")); + + var ex = Assert.Throws(() => db.KeyDelete("k")); + Assert.Contains("has not yet moved", ex.Message); + } + + [Fact] + public void AnUnmovedStreamingCommandThrowsToo() + { + // the scans are the one part [AutoDatabase] skips by category, so they are hand-written; this is + // here so that "hand-written" does not quietly become "forgotten" + var db = Target(new FakeExecutor("+OK\r\n")); + + Assert.Throws(() => db.HashScan("k")); + } + + [Fact] + public void TheWholeInterfaceIsImplemented() + { + // the point of the generator: this type satisfies IDatabase in full without anyone listing it + Assert.True(typeof(IDatabase).IsAssignableFrom(typeof(TransitionalDatabase))); + } +} From 74b5c888cda48fb6bf35144ef7bb4e13547be76a Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:44:00 +0100 Subject: [PATCH 088/360] Split TransitionalDatabase by command group, for parity with RespSurface TransitionalDatabase.Implemented.cs becomes TransitionalDatabase.Strings.cs, so the adapter is grouped the same way the surface it adapts is: RespSurface.Strings.cs declares ctx.Strings.Get, and TransitionalDatabase .Strings.cs is the IDatabase spelling of it. The two halves of a command move together, and a group is either done or visibly not. The sync bridge moves into TransitionalDatabase.cs, where the rest of the infrastructure lives - it is not a command group. csproj nests TransitionalDatabase.*.cs the way RespSurface.*.cs already is. No behaviour change: SER352 still reports 618. --- .../TransitionalDatabase.Implemented.cs | 97 ------------------- .../TransitionalDatabase.Strings.cs | 73 ++++++++++++++ .../Interpolated/TransitionalDatabase.cs | 28 ++++++ .../StackExchange.Redis.csproj | 1 + 4 files changed, 102 insertions(+), 97 deletions(-) delete mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs deleted file mode 100644 index 4def91dfd..000000000 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Implemented.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Interpolated -{ - /// - /// The commands that have actually moved to the RESP context surface. Everything else is generated by - /// [AutoDatabase] and throws; see TransitionalDatabase.cs. - /// - /// - /// - /// This is the file that grows as the transition proceeds, and the only one that needs touching to - /// move a command across: implementing a member here removes it from the generated set automatically, - /// because the generator skips what the class implements itself. - /// - /// - /// Note how little is here per command. The body is the whole implementation - no message type, no - /// result processor, no overload ladder - because the command already exists on the context surface - /// and this is purely the adapter from the old interface to it. - /// - /// - internal sealed partial class TransitionalDatabase - { - // ---- strings ------------------------------------------------------------------------------------ - - /// - public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) - => Wait(Context.Strings.Get(key, flags)); - - /// - public Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => Context.Strings.Get(key, flags).AsTask(); - - /// - public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => Wait(Context.Strings.Set(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags)); - - /// - public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => Context.Strings.Set(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags).AsTask(); - - // the two legacy When/TimeSpan? shapes, which is what most existing callers actually bind to. - // `When` converts implicitly to ValueCondition, and CreateOrKeepTtl is the same helper - // RedisDatabase uses, so these are pure adapters with no second opinion about semantics. - - /// - public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when) - => StringSet(key, value, expiry, keepTtl: false, when); - - /// - public bool StringSet(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when, CommandFlags flags) - => StringSet(key, value, expiry, keepTtl: false, when, flags); - - /// - public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when) - => StringSetAsync(key, value, expiry, keepTtl: false, when); - - /// - public Task StringSetAsync(RedisKey key, RedisValue value, System.TimeSpan? expiry, When when, CommandFlags flags) - => StringSetAsync(key, value, expiry, keepTtl: false, when, flags); - - /// - public bool StringSet(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) - => Wait(Context.Strings.Set(key, value, expiry, when, flags)); - - /// - public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) - => Context.Strings.Set(key, value, expiry, when, flags).AsTask(); - - // ---- the sync bridge ---------------------------------------------------------------------------- - - /// Block for an asynchronous result, applying the multiplexer's timeout. - /// - /// - /// Sync is deliberately deprioritised, so this is the cheap version rather than the right - /// one. A synchronously-completed result - notably a client-side cache hit - is taken directly and - /// costs nothing. Anything else blocks on a Task, which is sync-over-async: the async path - /// completes its task sources with RunContinuationsAsynchronously (see - /// ResultBox.cs), so the completion needs a thread-pool thread while this one is blocked - /// holding another. That is the failure mode SER307/SER308 exist to warn about. - /// - /// - /// The proper fix is routing rather than waiting: IRespExecutor already has a synchronous - /// Send, and RespExecutor.Send already uses it, so a context flag consulted by the - /// one shared funnel would make every sync call complete inline and reduce this method to its fast - /// path. Worth doing when sync stops being deprioritised - not before. - /// - /// - private T Wait(ValueTask pending) - { - if (pending.IsCompletedSuccessfully) return pending.Result; - - #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers - return multiplexer.Wait(pending.AsTask()); - #pragma warning restore SER308 - } - } -} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs new file mode 100644 index 000000000..5c0b46b62 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The string commands, where they have moved to the RESP context surface. + /// + /// + /// + /// One file per command group, named to match the group it adapts - RespSurface.Strings.cs + /// declares ctx.Strings.Get, and this file is the spelling of it. The + /// pairing is the point: the two halves of a command move together, and a group is either done or + /// visibly not. + /// + /// + /// Implementing a member here removes it from the generated set automatically, because + /// [AutoDatabase] skips whatever the class declares itself - so nothing has to be deleted from + /// a list of stubs, and SER352's count falls by itself. + /// + /// + /// Note how little each one is. The body is the whole implementation - no message type, no result + /// processor, no overload ladder - because the command already exists on the context surface and this + /// is purely the adapter from the old interface to it. + /// + /// + internal sealed partial class TransitionalDatabase + { + /// + public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Get(key, flags)); + + /// + public Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.Get(key, flags).AsTask(); + + /// + public bool StringSet(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Set(key, value, expiry, when, flags)); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => Context.Strings.Set(key, value, expiry, when, flags).AsTask(); + + /// + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => StringSet(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => StringSetAsync(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags); + + // the older When/TimeSpan? shapes, which is what most existing callers actually bind to; `When` + // converts implicitly to ValueCondition, and CreateOrKeepTtl is the same helper RedisDatabase + // uses, so these are pure adapters with no second opinion about semantics + + /// + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when) + => StringSet(key, value, expiry, keepTtl: false, when); + + /// + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => StringSet(key, value, expiry, keepTtl: false, when, flags); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when) + => StringSetAsync(key, value, expiry, keepTtl: false, when); + + /// + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => StringSetAsync(key, value, expiry, keepTtl: false, when, flags); + } +} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs index 364304cba..51a667613 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs @@ -71,6 +71,34 @@ private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation() => new($"This command has not yet moved to the RESP context surface (captured as '{typeof(TState).Name}')."); + // ---- the sync bridge ---------------------------------------------------------------------------- + + /// Block for an asynchronous result, applying the multiplexer's timeout. + /// + /// + /// Sync is deliberately deprioritised, so this is the cheap version rather than the right + /// one. A synchronously-completed result - notably a client-side cache hit - is taken directly and + /// costs nothing. Anything else blocks on a Task, which is sync-over-async: the async path + /// completes its task sources with RunContinuationsAsynchronously (see + /// ResultBox.cs), so the completion needs a thread-pool thread while this one is blocked + /// holding another. That is the failure mode SER307/SER308 exist to warn about. + /// + /// + /// The proper fix is routing rather than waiting: IRespExecutor already has a synchronous + /// Send, and RespExecutor.Send already uses it, so a context flag consulted by the + /// one shared funnel would make every sync call complete inline and reduce this method to its fast + /// path. Worth doing when sync stops being deprioritised - not before. + /// + /// + private T Wait(ValueTask pending) + { + if (pending.IsCompletedSuccessfully) return pending.Result; + + #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers + return multiplexer.Wait(pending.AsTask()); + #pragma warning restore SER308 + } + // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- public IBatch CreateBatch(object? asyncState = null) => throw new NotImplementedException(); diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index be4f00b76..53f3e6320 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -50,6 +50,7 @@ + From 9c74e2d7390ab5c379949afad5cc3c448397f788 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 14:49:36 +0100 Subject: [PATCH 089/360] Add the result-less Wait, and write down the consumption rule Wait(ValueTask) alongside Wait(ValueTask), for the commands that will use the result-less SendAsync. Unused for now, on purpose: the rule it carries is the kind that gets rediscovered the hard way. A ValueTask backed by an IValueTaskSource must have its result consumed exactly once - GetResult(token) is what lets the source finish its lifecycle and be reset or pooled. With no value to take, that call looks like a no-op and is not: abandoning the source leaks it, and the next borrower can see a stale token, which would surface nowhere near this code. Both overloads now spell it GetAwaiter().GetResult(). .Result would also consume on the generic one - it calls IValueTaskSource.GetResult(_token) - but only a reader who already knows that can tell, and it is the same rule, so it should look the same. Pinned by a test using an IValueTaskSource that counts GetResult calls; dropping the call fails it. --- .../Interpolated/TransitionalDatabase.cs | 33 +++++++++++++++- .../TransitionalDatabaseTests.cs | 39 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs index 51a667613..9ac74edb7 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs @@ -74,6 +74,8 @@ private static NotImplementedException NotMoved() // ---- the sync bridge ---------------------------------------------------------------------------- /// Block for an asynchronous result, applying the multiplexer's timeout. + /// The result type. + /// The operation to wait for. /// /// /// Sync is deliberately deprioritised, so this is the cheap version rather than the right @@ -92,13 +94,42 @@ private static NotImplementedException NotMoved() /// private T Wait(ValueTask pending) { - if (pending.IsCompletedSuccessfully) return pending.Result; + // spelled the same way as the result-less overload below, deliberately: .Result would also + // consume (it calls IValueTaskSource.GetResult(_token)), but only a reader who already + // knows that can tell - and the rule is the same rule, so it should look the same + if (pending.IsCompletedSuccessfully) return pending.GetAwaiter().GetResult(); #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers return multiplexer.Wait(pending.AsTask()); #pragma warning restore SER308 } + /// + /// The operation to wait for. + /// + /// The result-less twin, for commands that go through the SendAsync overload with no + /// TResult. Not used yet - added alongside the generic one deliberately, because the + /// consumption rule below is the kind of thing that gets rediscovered the hard way. + /// + private void Wait(ValueTask pending) + { + if (pending.IsCompletedSuccessfully) + { + // NOT a no-op, and not optional. A ValueTask backed by an IValueTaskSource must have its + // result consumed exactly once: GetResult(_token) is what lets the source complete its + // lifecycle and be reset or returned to its pool. Observing IsCompletedSuccessfully and + // returning would abandon it - the pooled source is never released, and the next operation + // to borrow it can see a stale token. There is no value to take here, which is precisely + // why it looks droppable and is not. + pending.GetAwaiter().GetResult(); + return; + } + + #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers + multiplexer.Wait(pending.AsTask()); // AsTask consumes the source too, so the branches stay exclusive + #pragma warning restore SER308 + } + // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- public IBatch CreateBatch(object? asyncState = null) => throw new NotImplementedException(); diff --git a/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs index 9903c0af1..b318c5131 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Text; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using System.Threading.Tasks.Sources; using StackExchange.Redis.Interpolated; using Xunit; @@ -89,6 +91,43 @@ public void AnUnmovedStreamingCommandThrowsToo() Assert.Throws(() => db.HashScan("k")); } + /// A ValueTask source that records whether its result was consumed. + private sealed class ConsumptionProbe : IValueTaskSource + { + public int GetResultCalls { get; private set; } + + public ValueTaskSourceStatus GetStatus(short token) => ValueTaskSourceStatus.Succeeded; + + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + => continuation(state); + + public void GetResult(short token) => GetResultCalls++; + } + + [Fact] + public void AResultLessWaitStillConsumesTheValueTask() + { + // The rule that is easy to lose: a ValueTask backed by an IValueTaskSource must have its result + // consumed exactly once, because GetResult(token) is what lets the source complete its lifecycle + // and be reset or pooled. With no value to take, the call looks droppable - so this is here to + // fail if someone drops it. Abandoning the source leaks it and can hand a stale token to whoever + // borrows it next, which is a bug that would surface nowhere near this code. + var probe = new ConsumptionProbe(); + var db = (TransitionalDatabase)Target(new FakeExecutor("+OK\r\n")); + + var wait = typeof(TransitionalDatabase).GetMethod( + "Wait", + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: [typeof(ValueTask)], + modifiers: null); + Assert.NotNull(wait); + + wait!.Invoke(db, [new ValueTask(probe, token: 0)]); + + Assert.Equal(1, probe.GetResultCalls); + } + [Fact] public void TheWholeInterfaceIsImplemented() { From bf588639af66cdae06e1778b4d24762aaec8a0a1 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:09:24 +0100 Subject: [PATCH 090/360] AsDatabase(); record the CLIENT TRACKING decisions (6.13, 6.14) RespDatabase.AsDatabase(multiplexer) returns IDatabase via TransitionalDatabase, completing a round trip that was one-way: IDatabase .Context already goes the other direction. The concrete type stays internal - the contract is IDatabase - so the transitional type can be renamed or deleted without a public API change. 6.13 records the tracking mode, checked against a live Redis 8.9.241 rather than inferred, with the server's own error text: - RESP3 only, no REDIRECT. Not just simpler: the redirected model has a race the single-connection model cannot have (invalidation arriving before the reply it invalidates). We already implement the documented mitigation, but there is no reason to pay for a protocol that needs it. - BCAST with the empty prefix. PREFIX does NOT map onto WithKeyPrefix: prefixes are connection-global, must not overlap - context prefixes routinely nest - and cannot be removed individually. - OPTIN/OPTOUT are incompatible with BCAST, so the question disappears, and with it any need for a new CommandFlags bit. Default mode is not OPTOUT: both track everything, only OPTOUT has an escape hatch. - Invalidate locally on every write (safe, cheap, closes the window), but do NOT enable NOLOOP: in default mode it leaves the key untracked after our own write, so anything whose key set we under-declare - EVAL with computed keys - goes permanently stale rather than briefly. 6.14 records that the cache is global (tracking is per-connection, and the server's key namespace ignores database numbers) while the TTL is not: how stale a caller tolerates is per-caller, and it is the one setting that cannot be added to IDatabase without a binary break. It must be applied on read rather than stamped on store, so one entry serves contexts with different tolerances. Two open points recorded: where it sits on the 48-byte context, and that it needs a default rather than only an override. --- design/interpolated-resp-writer.md | 116 ++++++++++++++++++ .../Interpolated/RespDatabase.cs | 32 ++++- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../TransitionalDatabaseTests.cs | 18 +++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index d53a0c0bb..807ced55f 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1882,6 +1882,122 @@ equally correct, so either outcome is a legitimate observation. It is bounded to invalidation, and everything after it is correct. Recorded as a deliberate tolerance rather than something to fix. +### 6.13 `CLIENT TRACKING`: the mode, decided + +Everything below was checked against a live Redis 8.9.241 rather than inferred; the error text is quoted +from the server. + +**RESP3 only; no `REDIRECT`.** Not merely because two connections are more work — the redirected model has +a race the single-connection model cannot have. The invalidation can arrive *before* the reply it +invalidates, so a client caches a value it has already been told to drop, and the documented workaround is +to write a placeholder entry before sending and refuse the fill if it disappears. We happen to implement +exactly that already (`TryBeginFill` + `Dependency.AllValid` + stamp-before-drop, §6.6), so we would +survive it — but there is no reason to pay for a protocol that requires it. When RESP3 is unavailable, +client-side caching must **refuse loudly**, not silently degrade into a cache nothing invalidates. + +**`BCAST`, with the empty prefix by default.** + +``` +CLIENT TRACKING on PREFIX foo → ERR PREFIX option requires BCAST mode to be enabled +CLIENT TRACKING on BCAST PREFIX foo PREFIX foob → ERR Prefix 'foo' overlaps with another provided + prefix 'foob'. Prefixes for a single client must + not overlap. +``` + +Multiple prefixes are an OR; no prefix under `BCAST` means the empty prefix, i.e. every key. + +**`PREFIX` does not map onto `WithKeyPrefix`, and this is the trap worth recording.** Prefixes are +connection-global, must not overlap, and cannot be removed individually ("to remove all prefixes, disable +and re-enable tracking"). Context key-prefixes routinely *nest* — `app:` and `app:users:` — which is +precisely the rejected case, and a context going out of scope has no way to deregister. So the prefix set +is an explicit connection-level tuning knob, never derived per-context. Registration is O(N²) and server +CPU scales with prefix count. + +The honest cost of `BCAST` with the empty prefix is a push for every key modified by anyone. Client-side +that is cheap — `OnInvalidate` is ~5-6ns and allocation-free, which is exactly why it was measured that way +(§6.6) — but the network cost is real, and is the reason `PREFIX` exists at all. + +**`OPTIN`/`OPTOUT` are therefore off the table.** + +``` +CLIENT TRACKING on BCAST OPTIN → ERR OPTIN and OPTOUT are not compatible with BCAST +CLIENT TRACKING on; CLIENT CACHING yes → ERR CLIENT CACHING YES is only valid when tracking is enabled + in OPTIN mode. +CLIENT TRACKING on; CLIENT CACHING no → ERR CLIENT CACHING NO is only valid when tracking is enabled + in OPTOUT mode. +``` + +Note the default mode is *not* `OPTOUT`: both track everything, but only `OPTOUT` unlocks per-command +exclusion, and the default mode has no escape hatch at all. Choosing `BCAST` removes the question. We lose +little: `CommandFlags.NoClientCache` already opts out client-side at zero protocol cost, and the only thing +a server-side opt-out buys is invalidation-table memory — which under `BCAST` is zero. + +*If we ever went default-mode:* `OPTIN` needs a positive flag (`CommandFlags.ClientCache`) and a +`CLIENT CACHING yes` pipelined immediately ahead of each command, with two traps — it applies to **all** +commands in a following `MULTI`, and to **all** commands executed by a following Lua script. + +**Invalidate locally on every write. Do not enable `NOLOOP`.** Two separate decisions that look like one. + +Local invalidation of the keys a write touches is a strict improvement, independent of `NOLOOP`: +over-invalidating is always safe (§6.6 accepts false invalidations by design), the frame already carries +key marks so it costs almost nothing, and it closes the window between our write landing and the push +coming back. Keyless flushes map to `OnFlush()`. + +`NOLOOP` is a different matter, and the server documentation is unusually blunt about why: + +> "With tracking in the default mode, the server removes the key from the invalidation table when the key +> is modified. If the connection that modified the key is using `NOLOOP`, Redis suppresses the invalidation +> message to that connection, **but the key is still no longer tracked for that connection after the +> write.**" + +So in default mode, `NOLOOP` without exact local invalidation is not "briefly stale" — it is +**permanently** stale: we keep the entry, the server has stopped tracking it, and a *third party's* later +write produces no message for us either. And "exact" is the problem: any command whose key set we +under-declare — `EVAL`/`EVALSHA` with computed keys, anything whose key spec we do not model — lands in +that case. Under `BCAST` there is no invalidation table and the hazard does not arise, which is another +point in `BCAST`'s favour, but it stays a later optimisation rather than part of the first cut. + +### 6.14 Global cache, contextual TTL + +**The cache is global.** Two facts force it. Tracking is per-*connection* (by client id), and the server +keeps *"a single keys namespace, not divided by database numbers"* — a change to `foo` in db 3 invalidates +`foo` cached from db 2, which §6.6 already handles (`InvalidationCrossesDatabases`). + +- In **default mode**, only the connection that *read* a key is told about it. So every connection serving + cacheable reads needs tracking on, and a push arriving on one connection must evict entries populated via + another — entries are keyed by (frame, database), not by connection. +- In **`BCAST` mode**, invalidations reach every client subscribed to the prefix regardless of who read, so + **one** tracking connection serves the whole multiplexer. + +Either way the cache is global; `BCAST` merely makes it clean — one subscription, one push stream, no +per-connection bookkeeping, no duplicate invalidations. So `WithCache` means "participate, or not" (plus +substitution in tests), **not** "bring your own": two different caches over one multiplexer is not +supportable, because the invalidation stream has exactly one destination. + +**The TTL is not global.** How stale a caller will tolerate is a per-caller policy, not a property of the +connection — and it is the one piece of cache configuration that *cannot* be added to the existing +surface, because `IDatabase.StringGet` cannot grow a parameter without a binary break (AGENTS.md). On the +context it is free and reaches every command without touching a signature, which is §9.4 paying off again. + +**It must be applied on read, not stamped on store.** The entry is shared, so the fill timestamp goes with +the entry and `TryGet` takes a maximum age from the *reading* context. One entry serves any number of +contexts with different tolerances; stamping at store time would force identical replies to be cached once +per distinct TTL. + +Two things still to settle: + +- **Where it sits on the context.** A `TimeSpan` field pushes `RespContext` past its 48 bytes; the service + slot keeps it there at the cost of a chain walk per cache read. That is the same trade `ChannelPrefix` + was measured for (§3.3), so measure rather than guess — noting this one is on the *hit* path, where the + alternative is a network round trip. +- **There should be a default, not only an override.** The server documentation recommends a maximum TTL on + every entry as a backstop against exactly the staleness bugs above. So: a default on the cache, overridable + per context. + +**Still unwired, and both come straight from the same documentation:** losing the connection must flush the +cache (`OnFlush()` exists; nothing calls it on disconnect), and there is no TTL of any kind today. + + ## 7. Analyzer rules The analyzer **does** reach consumers: `StackExchange.Redis.csproj:83-100` packs both diff --git a/src/StackExchange.Redis/Interpolated/RespDatabase.cs b/src/StackExchange.Redis/Interpolated/RespDatabase.cs index d25eb0e05..ce86e5fd6 100644 --- a/src/StackExchange.Redis/Interpolated/RespDatabase.cs +++ b/src/StackExchange.Redis/Interpolated/RespDatabase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using RESPite; @@ -42,5 +42,35 @@ public sealed class RespDatabase : IRespTarget /// A database bound to a different database index. /// The database index. public RespDatabase WithDatabase(int database) => new(Context.WithDatabase(database)); + + /// + /// This database as an , for handing to code written against the existing + /// interface. + /// + /// The multiplexer to report, and whose timeout the blocking members use. + /// The async state to carry on tasks this database produces. + /// + /// + /// The return trip. IDatabase.Context already goes the other way, so with this the two + /// surfaces interoperate in both directions and neither is a one-way door: new code can take a + /// context and still hand an to a library that wants one. + /// + /// + /// Commands that have not yet moved to the context surface throw from the returned instance - it is + /// a transitional database, and SER352 counts what is missing on every Release build. This is + /// not the route by which the interface eventually gets its new implementation; that happens when + /// GetDatabase() returns one directly. + /// + /// + /// The concrete type stays internal: the contract here is , which is + /// the whole point, and keeping it that way means the transitional type can be renamed, replaced or + /// deleted without a public API change. + /// + /// + public IDatabase AsDatabase(IConnectionMultiplexer multiplexer, object? asyncState = null) + { + if (multiplexer is null) throw new ArgumentNullException(nameof(multiplexer)); + return new TransitionalDatabase(this, multiplexer, asyncState); + } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 3658e30ed..2340db275 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -85,6 +85,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServices(object? services) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespDatabase +[SER010]StackExchange.Redis.Interpolated.RespDatabase.AsDatabase(StackExchange.Redis.IConnectionMultiplexer! multiplexer, object? asyncState = null) -> StackExchange.Redis.IDatabase! [SER010]StackExchange.Redis.Interpolated.RespDatabase.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespDatabase.RespDatabase(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespDatabase.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespDatabase! diff --git a/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs index b318c5131..370393736 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalDatabaseTests.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Sources; +using NSubstitute; using StackExchange.Redis.Interpolated; using Xunit; @@ -128,6 +129,23 @@ public void AResultLessWaitStillConsumesTheValueTask() Assert.Equal(1, probe.GetResultCalls); } + [Fact] + public void AsDatabaseCompletesTheRoundTrip() + { + // IDatabase.Context already goes new <- legacy; this is the other direction, so neither surface is + // a one-way door. Note the concrete type stays internal - the contract is IDatabase. + var executor = new FakeExecutor("$4\r\nmarc\r\n"); + var surface = new RespDatabase(new RespContext().WithExecutor(executor)); + + IDatabase legacy = surface.AsDatabase(Substitute.For()); + + Assert.Equal("marc", (string?)legacy.StringGet("user:1")); + Assert.Equal("*2|$3|GET|$6|user:1|", Assert.Single(executor.Sent)); + + // and back again, to the same context + Assert.Same(executor, ((IRespTarget)legacy).Context.Executor); + } + [Fact] public void TheWholeInterfaceIsImplemented() { From 934e8d2d2ba3091ef60c6aa16d68ccaed4227785 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:27:18 +0100 Subject: [PATCH 091/360] Single-flight: concurrent misses wait instead of stampeding RedundantFills counted this and nothing fixed it - its own test said so: "exactly what request combining would have collapsed into one round trip". Now TryAwaitInFlight lets a miss wait on a request already in flight, and Coalesced counts the stampedes that did not happen. The wait yields no value; waiters re-probe the cache. Handing the leader's payload across threads is the obvious design and worse: it is refcounted, so a waiter resuming after the leader released would need a dead buffer or a racily-retained one. Re-probing reuses TryGet's retain-and-recheck, and is automatically right when the leader's reply was not cacheable. Attaching is refused when anything the leader depends on has changed since it sent. That is read-your-own-writes: the in-flight reply predates the write, so serving it is observably wrong rather than merely stale. The check is Dependency.AllValid - the same invariant that decides whether a fill may be STORED decides whether a waiter may ATTACH. Three ordering constraints, each a bug first: - unregister while the key is alive (it IS the dictionary key, and completing a fill disposes it) - folding it into the same finally as the publish threw ObjectDisposedException on RefCountedBuffer - publish after the store, unregister before it - wake waiters on refusals too, not only on success Also fixes a pre-existing leak: a send that threw never completed the fill, so the key was never disposed. With registrations that would additionally hang every waiter, so both fill paths now abandon on throw. Sync callers do not coalesce - waiting on another caller's Task from a synchronous method is the sync-over-async problem avoided elsewhere. Deliberate while sync is deprioritised. Notes 6.15 records the design, including that our refresh needs no factory: the key IS the request, so re-sending it needs no delegate, no captured state and nothing of the caller's retained - the thing HybridCache's (TState, Func) shape exists to work around. Both mutants caught: disabling coalescing, and dropping the attach check. --- design/interpolated-resp-writer.md | 140 +++++++++++++++ .../Interpolated/RespClientCache.cs | 164 +++++++++++++++++- .../Interpolated/RespExecutor.cs | 100 ++++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 3 + .../RespCoalescingTests.cs | 123 +++++++++++++ 5 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespCoalescingTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 807ced55f..4460fb830 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1998,6 +1998,146 @@ Two things still to settle: cache (`OnFlush()` exists; nothing calls it on disconnect), and there is no TTL of any kind today. +### 6.15 Stampedes: single-flight, and stale-while-revalidate + +Reported from the field (Microsoft, on HybridCache): expiry and invalidation both produce **stampedes** — +the moment an entry goes, every concurrent reader of a hot key misses at once and they all hit the server +together. + +**We already measure this and do nothing about it.** `RedundantFills` counts exactly these collisions, and +its test says so in as many words: *"two callers miss on the same request and both go to the server - +exactly what request combining would have collapsed into one round trip"*. So the counter is a meter, not a +mitigation. + +There are **two** mechanisms, and they are usually conflated: + +- **Single-flight** — N concurrent misses on the same request become one round trip with N waiters. The + direct fix. +- **Stale-while-revalidate (SWR)** — serve the old value while a refresh runs, so the window in which a + stampede is even possible mostly stops existing. + +They are not independent: if N readers cross the refresh threshold together, the *background refresh* is +itself a stampede. So SWR's "refresh once" is single-flight wearing a different hat, and single-flight is +the thing to build first — it stands alone, and everything else needs it. + +#### Single-flight, and why sharing a reply is sound + +A waiter attaching to an in-flight request gets that request's reply. The justification is an ordering one: +the leader sent at T0, the waiter attached at T > T0, the reply lands at T1 > T. Had the waiter sent its +own request at T, it would have been answered at about T1 as well. **No linearisation the waiter can +observe distinguishes the two**, so the shared reply is a legitimate answer to its read. + +That argument has exactly one hole, and it is the same hole as everywhere else in this design: +**read-your-own-writes**. If the waiter (or anything else in this process) wrote the key after T0, the +leader's in-flight reply predates the write, and returning it is observably wrong — that gets reported as +corruption, not as staleness. + +The fix reuses machinery that already exists. Writes invalidate locally (§6.13), which bumps the key's +generation; the leader records the generation it sent at, and a waiter may attach **only if the dependency +generations still match**. That is `Dependency.AllValid` (§6.6) — the same invariant that decides whether a +fill may be *stored* decides whether a waiter may *attach*. If it fails, the waiter simply goes its own +way. + +Sharing the reply is also already safe for lifetime: entries hold a refcounted blob lease, so each waiter +takes its own reference and the reply outlives the leader. + +#### Stale-while-revalidate on expiry + +Falls straight out of the TTL work in §6.14 — two thresholds instead of one, both contextual and applied on +read: + +| age | behaviour | +|---|---| +| `< soft` | fresh hit | +| `soft ≤ age < hard` | **serve stale**, and trigger a refresh, once | +| `≥ hard` | miss | + +The once-only flag lives on the *shared entry* while the thresholds are *per-context*. That is right rather +than a compromise: whoever crosses their own soft bar first triggers a refresh everyone benefits from. The +flag must clear on failure as well as success, with backoff — otherwise one failing server leaves the entry +pinned stale until hard expiry. + +#### Stale-while-revalidate on invalidation + +Also possible, under "you cannot prove the order, so any order is valid" — but **only for third-party +writes**. An invalidation we caused ourselves is not a race, it is a fact, and serving through it breaks +read-your-own-writes. + +The carve-out needs no new bookkeeping: + +- **local invalidation** (we wrote it, on any connection — the cache is global, §6.14) → **hard drop** +- **server push** → eligible for the soft window + +and because the local invalidation happens *before* the echoed push arrives, our own write's entry is +already gone when that push lands. Ordering does the work. + +**Measure the window from first notice, not from the invalidation.** Measuring from the invalidation means +a timestamp on the key node in table 2 — eight more bytes per node, on the `OnInvalidate` path that is +currently ~5-6ns and allocation-free, which is not a path to disturb for this. First-notice needs one field +on the entry, and is the better semantic anyway: the window is "how long we serve stale while a refresh is +in flight", which is a fact about refresh latency, not about when somebody else wrote. + +#### The refresh needs no factory, because we already hold the request + +Most caches cannot refresh themselves. The key is an opaque string, so the cache must be *handed* a way to +recompute the value — and `HybridCache` pays a real price for that: its `(TState, Func)` +shape exists specifically to avoid a lambda allocation per call, which is awkward to use and keeps +arbitrary caller objects alive for as long as the operation is pending. For a *background* refresh it is +worse again, because the state and the delegate would have to be retained on the entry, pinning user +objects inside the cache for as long as the entry lives. + +None of that applies here, and it falls straight out of §6: **the cache key IS the rendered request**. To +refresh an entry we re-send its own key. No factory, no captured state, no delegate, nothing of the +caller's retained — the only thing held is the pooled buffer the cache already owns, and `Detach` preserves +the original `CommandFlags` so the refresh goes out exactly as the original did. + +It is also **handler-agnostic**: the cache stores the raw reply and parsing happens per-caller, so a +refresh does not need to know what anybody intended to turn the bytes into. That is what makes a background +refresh a few lines rather than a design. + +#### Risks to design for, not discover + +- **Compounding staleness on a hot-written key.** Every refresh is invalidated in flight, `AllValid` + correctly refuses the store, and the entry serves stale indefinitely. Needs an absolute cap — consecutive + stale serves, or a wall-clock bound from first notice — after which it is a real miss regardless. +- **The refresh's store failing is the normal case** under that write pressure, not an edge case; it is the + path the once-only flag has to handle. +- **The two defaults differ.** Expiry-SWR is a reasonable default. Invalidation-SWR is deliberately serving + data the server has *told* us is wrong, and should be explicit, per-context, and named so that choosing it + is a decision rather than an inheritance. + +#### Built: single-flight + +Implemented, with SWR still to come. `TryAwaitInFlight` lets a miss wait on a request already in flight; +`TryBeginFill` registers the leader; the registration is released on **every** path, including a send that +throws — which previously leaked the key silently and would now also hang every waiter. + +Three ordering constraints, each of which was a bug first: + +- **Unregister while the key is alive.** The key is the dictionary key, so removing it hashes and compares + the buffer — and completing a fill disposes that key. Folding unregistration into the same `finally` as + the publish threw `ObjectDisposedException` on `RefCountedBuffer`. +- **Publish after the store, unregister before it.** A waiter wakes and re-probes, so it must not be woken + before there is anything to find; and it must not be able to re-attach to a registration whose reply has + already arrived. A caller landing in the gap between the two finds neither and sends for itself — a missed + coalescing opportunity, not a wrong answer. +- **Waiters are woken on refusals too**, not only on success. They then miss and fetch for themselves, + which is what they would have done anyway. + +**Sync callers do not coalesce.** Waiting on another caller's `Task` from a synchronous method is the +sync-over-async problem this design avoids elsewhere, so `Send` still issues its own request. Deliberate, +given sync is deprioritised; it closes when the executor grows a synchronous wait. + +`Coalesced` counts the stampedes that did not happen, and is the counterpart to `RedundantFills`. The two +together are the useful signal: coalesced rising while redundant stays flat is the shape you want. + +#### Consequence for the context + +Soft window, hard TTL, invalidation-SWR on/off, staleness cap — four knobs, all contextual for the reasons +in §6.14. Growing `RespContext` field-by-field past its 48 bytes for those is the wrong shape; they want a +single small `CacheOptions` in the service slot, which is the pattern `ChannelPrefix` already set (§3.3). + + ## 7. Analyzer rules The analyzer **does** reach consumers: `StackExchange.Redis.csproj:83-100` packs both diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 88339ada2..bc75bdbfd 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using RESPite; using RESPite.Messages; @@ -43,6 +44,9 @@ namespace StackExchange.Redis.Interpolated public sealed class RespClientCache : IDisposable { private readonly ConcurrentDictionary _entries = new(); + + /// Requests currently being fetched, so concurrent misses can wait rather than pile on. + private readonly ConcurrentDictionary _inFlight = new(); private readonly RespKeyTable _keys; private long _stored; private long _refusedByFlags; @@ -50,6 +54,7 @@ public sealed class RespClientCache : IDisposable private long _refusedRaced; private long _redundantFills; private long _refusedError; + private long _coalesced; /// Create a cache. /// Initial size hint for the tracked-key table. @@ -98,6 +103,19 @@ public sealed class RespClientCache : IDisposable /// public long RedundantFills => Volatile.Read(ref _redundantFills); + /// Misses that waited for a request already in flight instead of sending their own. + /// + /// The stampedes that did not happen, and the direct counterpart of + /// : before single-flight every one of these was a duplicate round trip. + /// Watching the two together is the useful thing - coalesced rising while redundant stays flat is + /// the shape you want, and redundant rising with it means requests are arriving faster than the + /// leader can register, or their dependencies are changing under them. See design notes 6.15. + /// + public long Coalesced => Volatile.Read(ref _coalesced); + + /// Requests currently in flight with at least one waiter attached. + public int InFlightCount => _inFlight.Count; + /// Fills refused because the reply was an error. /// /// See for why errors are not cacheable. A non-trivial count here is @@ -237,15 +255,92 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, deps[i] = new Dependency(node, generation); } - fill = new RespFill(frame.Detach(flags), database, deps); + var key = frame.Detach(flags); + + // Register as the leader for this request, so concurrent misses can wait on us instead of each + // sending their own copy. Losing the race is not a failure: we simply lead without a slot, which + // is exactly the behaviour before single-flight existed, and RedundantFills still counts it. + var slot = new InFlight(deps); + if (!_inFlight.TryAdd(new EntryKey(key, database), slot)) slot = null; + + fill = new RespFill(key, database, deps, this, slot); return true; } /// - /// Complete a fill, storing the response only if nothing it depends on was invalidated while the - /// command was in flight. + /// Wait for a request that is already in flight rather than sending a second copy of it. /// - /// false if the fill was abandoned; the response must not be cached. + /// The rendered request. + /// The database the request runs against. + /// Completes when the leader's reply has been dealt with. + /// true if there was something to wait for. + /// + /// + /// The wait yields no value - the caller re-probes the cache afterwards. Handing the leader's + /// payload across is the obvious design and it is worse: the payload is reference-counted, so a + /// waiter resuming after the leader released its reference would have to be handed a dead buffer or + /// a racily-retained one. Re-probing reuses , whose retain-and-recheck is + /// already correct, and gives the right answer for free when the leader's reply turned out not to be + /// cacheable at all. + /// + /// + /// Attaching is refused if anything the leader depends on has changed since it sent. This is + /// the read-your-own-writes case: if this process wrote one of those keys after the leader's send, + /// the reply in flight predates the write, and serving it would be observably wrong rather than + /// merely stale. The check is - the same invariant that decides + /// whether a fill may be stored decides whether a waiter may attach. + /// + /// + /// Sharing is sound otherwise because no observer can tell the difference: the leader sent at T0, + /// the waiter arrived at T greater than T0, the reply lands at T1 greater than T. Had the waiter + /// sent its own request at T it would have been answered at about T1 too, so the shared reply is a + /// legitimate answer to its read. See design notes section 6.15. + /// + /// + public bool TryAwaitInFlight(in RespRequest frame, int database, [NotNullWhen(true)] out Task? pending) + { + if (_inFlight.TryGetValue(new EntryKey(frame, database), out var slot) + && Dependency.AllValid(slot.Dependencies)) + { + Interlocked.Increment(ref _coalesced); + pending = slot.Completion; + return true; + } + + pending = null; + return false; + } + + /// + /// Release this fill's in-flight registration, waking anything that attached to it. + /// + /// + /// Remove before publishing. The other order leaves a window in which a woken waiter re-probes + /// the cache, misses, and re-attaches to a registration that is about to be removed - waiting on a + /// reply that has already arrived. Removing first makes the registration unreachable before anyone + /// is told to look again. + /// + /// Idempotent, and a no-op for a fill that lost the race to register (which leads anyway - see + /// ). + /// + /// + internal void Unregister(in RespFill fill) + { + // MUST run while fill.Key is still alive: the key is the dictionary key, so removing it hashes + // and compares the buffer. Completing a fill disposes that key, so this cannot be folded into + // the finally alongside Publish. + if (fill.Slot is InFlight) _inFlight.TryRemove(new EntryKey(fill.Key, fill.Database), out _); + } + + /// Wake anything waiting on this fill, once its result is visible. + /// + /// Strictly after , so a woken waiter that misses cannot re-attach to a + /// registration whose reply has already arrived and wait for a second one that never comes. A waiter + /// arriving in the gap between the two finds neither a registration nor an entry and sends for + /// itself - a missed coalescing opportunity, not a wrong answer. + /// + internal static void Publish(in RespFill fill) => (fill.Slot as InFlight)?.Publish(); + /// /// Complete a fill, storing the reply only if nothing it depends on was invalidated while the /// command was in flight. @@ -264,6 +359,22 @@ public bool TryComplete(in RespFill fill, RespPayload response) if (response is null) throw new ArgumentNullException(nameof(response)); if (fill.Key.IsEmpty) return false; + Unregister(in fill); + try + { + return TryCompleteCore(in fill, response); + } + finally + { + // AFTER the store, on every path including the refusals: a waiter wakes and re-probes the + // cache, so it must not be woken before there is anything to find. Refusals wake them too - + // they then miss and fetch for themselves, which is what they would have done anyway. + Publish(in fill); + } + } + + private bool TryCompleteCore(in RespFill fill, RespPayload response) + { if (!IsCacheableReply(response.Span)) { Interlocked.Increment(ref _refusedError); @@ -465,6 +576,24 @@ internal static bool AllValid(Dependency[] dependencies) } } + /// A request currently being fetched, and what it depended on when it was sent. + /// + /// so that completing a fill never + /// runs a waiter's continuation - and therefore its parse - on the thread that is finishing the + /// leader's own reply. + /// + private sealed class InFlight(Dependency[] dependencies) + { + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal Dependency[] Dependencies { get; } = dependencies; + + internal Task Completion => _completion.Task; + + /// Release the waiters; they re-probe the cache for themselves. + internal void Publish() => _completion.TrySetResult(true); + } + private sealed class Entry(RespPayload payload, Dependency[] dependencies) { internal RespPayload Payload { get; } = payload; @@ -490,11 +619,13 @@ private readonly struct EntryKey(RespRequest frame, int database) : IEquatable + /// The in-flight registration to release when this fill ends, if this fill won the race to make + /// one. Typed as because the slot type is private to the cache. + /// + internal object? Slot { get; } + + /// The cache that issued this fill, and which owns releasing the registration. + internal RespClientCache? Owner { get; } + /// Abandon the fill without caching anything. - public void Abandon() => Key.Dispose(); + /// + /// Waiters are released here too. A failed request that kept its registration would strand + /// everyone who attached to it until their own cancellation fired - and they would be waiting on + /// a reply that is never coming. + /// + public void Abandon() + { + Owner?.Unregister(in this); // while Key is still alive - it is the dictionary key + Publish(in this); + Key.Dispose(); + } } } } diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index dbaeff90d..44c08c8f7 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -121,10 +121,25 @@ public static TResult Send( { if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; + // NOTE: no in-flight wait here. Coalescing means waiting on someone else's Task, and doing + // that from a synchronous caller is the sync-over-async problem this design avoids + // elsewhere; sync callers therefore still send their own copy, exactly as before. Sync is + // deprioritised (see TransitionalDatabase), so this is a deliberate gap rather than an + // oversight - it closes when the executor gains a synchronous wait. if (cache.TryBeginFill(ref request, executor.Database, flags, out var fill)) { - // generations captured above, BEFORE this send - var filled = executor.Send(fill.Key); + RespPayload filled; + try + { + // generations captured above, BEFORE this send + filled = executor.Send(fill.Key); + } + catch + { + fill.Abandon(); // release any waiters, and the key + throw; + } + try { cache.TryComplete(fill, filled); @@ -189,6 +204,13 @@ public static ValueTask SendAsync( return new ValueTask(cached); } + // somebody is already fetching exactly this - wait for them instead of sending a second + // copy. See design notes 6.15; this is the whole of the stampede fix at the call site. + if (cache.TryAwaitInFlight(request.AsLookupKey(), executor.Database, out var pending)) + { + return AwaitShared(executor, request.Detach(flags), pending, handler, cache, cancellationToken); + } + if (cache.TryBeginFill(ref request, executor.Database, flags, out var fill)) { return AwaitFill(executor, fill, handler, cache, cancellationToken); @@ -327,7 +349,19 @@ private static async ValueTask AwaitFill( RespClientCache cache, CancellationToken cancellationToken) { - var response = await executor.SendAsync(fill.Key, cancellationToken).ConfigureAwait(false); + RespPayload response; + try + { + response = await executor.SendAsync(fill.Key, cancellationToken).ConfigureAwait(false); + } + catch + { + // a fill that never completes strands its waiters on a reply that is never coming, and + // leaks the key; Abandon does both halves + fill.Abandon(); + throw; + } + try { cache.TryComplete(fill, response); @@ -339,6 +373,66 @@ private static async ValueTask AwaitFill( } } + /// + /// Wait for a request already in flight, then take the answer from the cache. + /// + /// + /// + /// The wait carries no value; the leader's payload is reference-counted and handing it across + /// threads would mean racing its release. Re-probing instead reuses TryGet's retain-and- + /// recheck, and is automatically right when the leader's reply turned out not to be cacheable. + /// + /// + /// The fallback send is not a failure path - it is what this caller would have done anyway without + /// coalescing, so the worst case is exactly today's behaviour plus one wait. + /// + /// + /// The wait is bounded by the leader's own request rather than by this caller's token: the leader + /// always completes its fill, including when it throws. A caller with a shorter deadline than the + /// leader therefore waits longer than it asked to, which is the one rough edge here. + /// + /// + private static async ValueTask AwaitShared( + IRespExecutor executor, + RespRequest owned, + Task pending, + IRespHandler handler, + RespClientCache cache, + CancellationToken cancellationToken) + { + try + { + await pending.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (cache.TryGet(owned, executor.Database, out var hit)) + { + try + { + return handler.Parse(hit.Span); + } + finally + { + hit.Release(); + } + } + + var response = await executor.SendAsync(owned, cancellationToken).ConfigureAwait(false); + try + { + return handler.Parse(response.Span); + } + finally + { + response.Release(); + } + } + finally + { + owned.Dispose(); + } + } + private static async ValueTask AwaitUncached( IRespExecutor executor, RespRequest request, diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2340db275..1586a3d2e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -22,6 +22,9 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RedundantFills.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Coalesced.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.InFlightCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryAwaitInFlight(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out System.Threading.Tasks.Task? pending) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedByFlags.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedError.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long diff --git a/tests/StackExchange.Redis.Tests/RespCoalescingTests.cs b/tests/StackExchange.Redis.Tests/RespCoalescingTests.cs new file mode 100644 index 000000000..cc989b86c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCoalescingTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Single-flight: concurrent misses on the same request wait for one round trip instead of each sending +/// their own. See design notes 6.15. +/// +public class RespCoalescingTests +{ + /// An executor whose replies are held until the test lets them go. + private sealed class GatedExecutor(string reply) + { + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal int Sends; + + internal Executor Interface => new(this, reply); + + internal void Release() => _gate.TrySetResult(true); + + internal void Fail() => _gate.TrySetException(new InvalidOperationException("boom")); + + internal Task Gate => _gate.Task; + + internal sealed class Executor(GatedExecutor owner, string reply) : IRespExecutor + { + public int Database => 0; + + public RespPayload Send(in RespRequest request) => throw new NotSupportedException("async only"); + + public async ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref owner.Sends); + await owner.Gate.ConfigureAwait(false); + return RespPayload.Create(Encoding.UTF8.GetBytes(reply)); + } + } + } + + private const CommandFlags Readable = CommandFlags.CommandRetryReadOnly; + + private static ValueTask Get(RespContext context, RedisKey key) + => context.SendAsync($"{RedisCommand.GET}{key}", Readable); + + [Fact] + public async Task ConcurrentMissesShareOneRoundTrip() + { + using var cache = new RespClientCache(); + var gated = new GatedExecutor("$5\r\nhello\r\n"); + var context = new RespContext().WithExecutor(gated.Interface).WithCache(cache); + + // the probe and the registration happen synchronously, before the first await - so by the time + // this returns, the second caller has something to attach to + var first = Get(context, "k"); + Assert.Equal(1, cache.InFlightCount); + + var second = Get(context, "k"); + var third = Get(context, "k"); + + gated.Release(); + + Assert.Equal("hello", (string?)await first); + Assert.Equal("hello", (string?)await second); + Assert.Equal("hello", (string?)await third); + + Assert.Equal(1, gated.Sends); // this is the whole point + Assert.Equal(2, cache.Coalesced); + Assert.Equal(0, cache.RedundantFills); + Assert.Equal(0, cache.InFlightCount); + } + + [Fact] + public async Task AFailedLeaderDoesNotStrandItsWaiters() + { + // a fill that throws must still release its registration, or everyone attached to it waits on a + // reply that is never coming - and the registration would linger, catching later callers too + using var cache = new RespClientCache(); + var gated = new GatedExecutor("$5\r\nhello\r\n"); + var context = new RespContext().WithExecutor(gated.Interface).WithCache(cache); + + var first = Get(context, "k"); + var second = Get(context, "k"); + + gated.Fail(); + + await Assert.ThrowsAsync(async () => await first); + + // the waiter wakes, finds nothing cached, and fetches for itself - which also throws, because this + // executor is still failing. What matters is that it COMPLETED rather than hanging. + await Assert.ThrowsAsync(async () => await second); + Assert.Equal(0, cache.InFlightCount); + } + + [Fact] + public async Task AWriteWhileInFlightPreventsAttaching() + { + // read-your-own-writes. The leader's reply predates the write, so a caller arriving after the write + // must NOT be given it - it fetches for itself instead. Same invariant that guards the store. + using var cache = new RespClientCache(); + var gated = new GatedExecutor("$5\r\nhello\r\n"); + var context = new RespContext().WithExecutor(gated.Interface).WithCache(cache); + + var first = Get(context, "k"); + Assert.Equal(1, cache.InFlightCount); + + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); // something wrote it + + var second = Get(context, "k"); + gated.Release(); + + Assert.Equal("hello", (string?)await first); + Assert.Equal("hello", (string?)await second); + + Assert.Equal(2, gated.Sends); // the second did NOT attach + Assert.Equal(0, cache.Coalesced); + } +} From 913e81045eb751b5286b7736ea8874fd90501bb1 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:31:36 +0100 Subject: [PATCH 092/360] Use RespReader.IsOK() rather than Is("OK"u8) Not only tidier: IsOK reads the two bytes as one ushort and compares against both OK and ok, aggressively inlined, where Is() goes through SequenceEqual. So it is faster on the path it was written for ("go mad with this, because it is used so often") and accepts '+ok' as well as '+OK', which the literal comparison did not. Internal to RESPite, but InternalsVisibleTo covers StackExchange.Redis. --- src/StackExchange.Redis/Interpolated/RespSurface.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 65a457628..4cc6adc09 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -97,7 +97,7 @@ public bool Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); - return reader.Is("OK"u8); + return reader.IsOK(); // one 16-bit compare, inlined - and accepts '+ok' as well as '+OK' } } } From a97a58cd9755d46b74fa5e91cb45832760916975 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:35:38 +0100 Subject: [PATCH 093/360] Correct the TTL default reasoning in 6.14 The note said a default could wait because nothing invalidates yet. That is backwards twice over: the absence of a TTL is not the absence of a policy, it is TTL = infinity, so an entry that is never invalidated is PERMANENTLY stale - strictly worse than briefly over-stale. And while delivery is unwired the TTL is the only freshness mechanism there is, so the backstop matters more, not less. The server docs recommend exactly this. Proposes 60 seconds as a safety bound rather than a tuning knob, with the reasoning (it covers a missed invalidation; disconnect-flush plus keepalive detects a real disconnect far sooner, so a minute bounds the damage when detection itself fails), and records that the right default may be two-tier: generous when CLIENT TRACKING is confirmed live, short or refusing to cache when it is not. --- design/interpolated-resp-writer.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 4460fb830..2e7b58bed 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1990,9 +1990,27 @@ Two things still to settle: slot keeps it there at the cost of a chain walk per cache read. That is the same trade `ChannelPrefix` was measured for (§3.3), so measure rather than guess — noting this one is on the *hit* path, where the alternative is a network round trip. -- **There should be a default, not only an override.** The server documentation recommends a maximum TTL on - every entry as a backstop against exactly the staleness bugs above. So: a default on the cache, overridable - per context. +- **There must be a default, and it must be finite.** There is no such thing as "no TTL policy" - the + absence of a TTL is a policy, and it is `TTL = infinity`. An entry that is never invalidated and never + expires is **permanently** stale, which is strictly worse than being briefly over-stale. The server + documentation says so directly: *"Putting a max TTL on every key is a good idea, even if it has no TTL. + This protects against bugs or connection issues that would make the client have old data in the local + copy."* + + Note which way the argument runs. "Invalidation delivery is not wired yet, so a number would be + arbitrary" is exactly backwards: while delivery is unwired, the TTL is the **only** freshness mechanism + there is, so the backstop matters more, not less. + + **Proposed default: 60 seconds**, and it is a safety bound rather than a tuning knob. What it protects + against is a missed invalidation - a connection blip we did not notice, or a bug. Flushing on disconnect + (still unwired) plus keepalive detects a real disconnect within seconds to tens of seconds, so a minute + is comfortably longer than detection while still bounding the damage when detection itself fails. + + **A second tier is worth considering**: the right default depends on whether invalidation is actually + live. With `CLIENT TRACKING` confirmed, the TTL is a backstop against rare losses and can be generous. + Without it, the cache is a stale-data generator with a timer, and the honest options are a much shorter + bound or refusing to cache at all. Either way the client should be loud about which regime it is in, + rather than the difference being invisible. **Still unwired, and both come straight from the same documentation:** losing the connection must flush the cache (`OnFlush()` exists; nothing calls it on disconnect), and there is no TTL of any kind today. From 4f02b657d4ef1d2ed32dee9fb70c18aec20480c7 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:46:36 +0100 Subject: [PATCH 094/360] Prove invalidation delivery against a real server The last part of the cache resting on reasoning rather than evidence. A dedicated RESP3 + BCAST connection (TrackingExecutor) feeds real invalidation pushes into RespClientCache, and RespTrackingTests checks the whole loop: a third party's write evicts what we cached, non-ASCII key bytes match the ones we recorded, one push clears several entries, a flush clears everything, and a pub/sub delivery on the same connection disturbs nothing. Wire format captured rather than assumed, and recorded in 6.13: SET >2 $10 invalidate *1 $9 probe:key MSET x3 >2 $10 invalidate *3 ... FLUSHDB >2 $10 invalidate _ The second element is an array of keys or a RESP3 null - never a string - which is exactly why the existing pipeline drops these: its push handling expects pub/sub shape. Key expiry invalidates too, not just explicit writes. Three things this flushed out that reasoning had not: - Telling an invalidation from a pub/sub delivery is subtler than "push means out-of-band": subscribe/unsubscribe confirmations are ALSO pushes but ARE command replies. The first cut would have handed a delivery back as somebody's reply. - BCAST with no prefix means every key the whole test suite touches arrives here, so any assertion counting invalidations counts the suite's traffic. Fixed with a PREFIX per test, which exercises PREFIX for free. - Our own writes echo back (NOLOOP off), so a setup write races the baseline. Hence SettleAsync - and a concrete illustration of the NOLOOP discussion. Also fixes a destructive test of my own making: AFlushDropsEverything called FlushDatabaseAsync on the shared primary and wiped the database out from under every concurrent test (11 unrelated failures). It now flushes an otherwise-unused database; tracking is database-agnostic, so the push arrives either way. Mutation-tested: reading only the first key of a push, ignoring a null payload, and dropping the 'invalidate' discriminator are all caught. The third initially survived, which is what prompted the pub/sub test. --- design/interpolated-resp-writer.md | 31 +++ .../Helpers/TrackingExecutor.cs | 247 ++++++++++++++++++ .../RespTrackingTests.cs | 228 ++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs create mode 100644 tests/StackExchange.Redis.Tests/RespTrackingTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 2e7b58bed..4ab5281c4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1957,6 +1957,37 @@ under-declare — `EVAL`/`EVALSHA` with computed keys, anything whose key spec w that case. Under `BCAST` there is no invalidation table and the hazard does not arise, which is another point in `BCAST`'s favour, but it stays a later optimisation rather than part of the first cut. +#### The invalidation push, on the wire + +Captured from a live server rather than read off a page, because the exact shape is what the integration +turns on: + +``` +third-party SET >2\r\n$10\r\ninvalidate\r\n*1\r\n$9\r\nprobe:key\r\n +MSET m:1 m:2 m:3 >2\r\n$10\r\ninvalidate\r\n*3\r\n$3\r\nm:1\r\n$3\r\nm:2\r\n$3\r\nm:3\r\n +FLUSHDB >2\r\n$10\r\ninvalidate\r\n_\r\n +our own SET +OK\r\n followed by the same push (NOLOOP off; reply first, then push) +``` + +- The second element is **an array of keys, or a RESP3 null** — *never* a string. That is exactly why the + existing pipeline drops these: its push handling expects pub/sub shape (`message` / channel / payload, + all strings), so an invalidation fails the "second element is a string" test and is discarded. +- **One push can name several keys.** A handler that reads only the first leaves entries live. +- **Key expiry invalidates too**, not only explicit writes — confirmed by watching a `PX 150` key. +- `BCAST` really does report keys we never read, and `PREFIX` filters exactly as documented. + +**Telling an invalidation from a pub/sub delivery is the whole of the discrimination the real pipeline +needs to add**, and getting it backwards is not a no-op in either direction: a channel name read as a key +list, or a delivery handed back as somebody's reply. Note the third case — `subscribe`/`unsubscribe` +confirmations are *also* typed as pushes in RESP3 but *are* the reply to a command, so "push means +out-of-band" is too simple. + +Proven end to end by `RespTrackingTests` against a real server, through a dedicated RESP3 `BCAST` +connection (`TrackingExecutor`): a third party's write evicts what we cached, non-ASCII key bytes match, +one push clears several entries, a flush clears everything, and a pub/sub delivery on the same connection +disturbs nothing. All four parsing steps are mutation-tested — the discriminator check initially survived +its mutant, which is what prompted the pub/sub test. + ### 6.14 Global cache, contextual TTL **The cache is global.** Two facts force it. Tracking is per-*connection* (by client id), and the server diff --git a/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs new file mode 100644 index 000000000..feb0279fd --- /dev/null +++ b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Messages; +using StackExchange.Redis.Interpolated; + +namespace StackExchange.Redis.Tests; + +/// +/// A minimal RESP3 executor on its own connection, with CLIENT TRACKING enabled, that feeds +/// invalidation pushes straight into a . +/// +/// +/// +/// The point is to prove the delivery path - that a real server's invalidation actually reaches +/// OnInvalidate, with key bytes that match what the cache recorded. Everything else about the cache +/// was already tested against fakes; this is the half that was resting on reasoning. +/// +/// +/// Deliberately NOT production plumbing. It owns one socket, has no reconnect, no pipelining beyond a FIFO +/// of pending replies, and no failover. Routing pushes through the real PhysicalConnection is +/// separate work - and doing it here first means that work starts from a known-good target rather than a +/// guess about the wire format. See design notes 6.13. +/// +/// +/// Mode is RESP3 + BCAST, per the decision in 6.13: invalidations arrive in-band on the same +/// connection, so there is no redirect race, and broadcasting needs no server-side per-client memory. +/// +/// +internal sealed class TrackingExecutor : IRespExecutor, IDisposable +{ + private readonly Socket _socket; + private readonly NetworkStream _stream; + private readonly RespClientCache _cache; + private readonly ConcurrentQueue> _pending = new(); + private readonly CancellationTokenSource _shutdown = new(); + + private int _invalidations, _flushes, _keysInvalidated; + + /// Invalidation pushes received. + internal int Invalidations => Volatile.Read(ref _invalidations); + + /// Keys named across all those pushes; a single push can carry several. + internal int KeysInvalidated => Volatile.Read(ref _keysInvalidated); + + /// Flush pushes received (the null payload). + internal int Flushes => Volatile.Read(ref _flushes); + + public int Database => 0; + + private TrackingExecutor(Socket socket, RespClientCache cache) + { + _socket = socket; + _stream = new NetworkStream(socket, ownsSocket: false); + _cache = cache; + } + + internal static async Task ConnectAsync(string host, int port, RespClientCache cache, params string[] prefixes) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(host, port).ConfigureAwait(false); + + var executor = new TrackingExecutor(socket, cache); + _ = Task.Run(executor.ReadLoopAsync); + + await executor.CommandAsync("HELLO", "3").ConfigureAwait(false); + + var tracking = new string[3 + (prefixes.Length * 2)]; + tracking[0] = "CLIENT"; + tracking[1] = "TRACKING"; + tracking[2] = "on"; + var next = 3; + foreach (var prefix in prefixes) + { + tracking[next++] = "PREFIX"; + tracking[next++] = prefix; + } + + // BCAST goes last so it follows any PREFIX arguments, which is how the server documents it + var args = new string[tracking.Length + 1]; + Array.Copy(tracking, args, tracking.Length); + args[args.Length - 1] = "BCAST"; + + var reply = await executor.CommandAsync(args).ConfigureAwait(false); + var text = Encoding.UTF8.GetString(reply); + if (!text.StartsWith("+OK", StringComparison.Ordinal)) + { + executor.Dispose(); + throw new InvalidOperationException("CLIENT TRACKING refused: " + text.Trim()); + } + + return executor; + } + + /// Send an ad-hoc command, for setup and for the test's own writes. + internal async Task CommandAsync(params string[] parts) + { + var payload = new StringBuilder().Append('*').Append(parts.Length).Append("\r\n"); + foreach (var part in parts) + { + payload.Append('$').Append(Encoding.UTF8.GetByteCount(part)).Append("\r\n").Append(part).Append("\r\n"); + } + + return await SendRawAsync(Encoding.UTF8.GetBytes(payload.ToString())).ConfigureAwait(false); + } + + private async Task SendRawAsync(byte[] frame) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // enqueue BEFORE writing: the reply can arrive before the write call returns + _pending.Enqueue(completion); + await _stream.WriteAsync(frame, 0, frame.Length).ConfigureAwait(false); + await _stream.FlushAsync().ConfigureAwait(false); + return await completion.Task.ConfigureAwait(false); + } + + public RespPayload Send(in RespRequest request) + => throw new NotSupportedException("This harness is async-only."); + + public async ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + { + var reply = await SendRawAsync(request.Span.ToArray()).ConfigureAwait(false); + return RespPayload.Create(reply); + } + + private async Task ReadLoopAsync() + { + var buffer = new byte[16 * 1024]; + var have = 0; + + try + { + while (!_shutdown.IsCancellationRequested) + { + if (have == buffer.Length) Array.Resize(ref buffer, buffer.Length * 2); + + var read = await _stream.ReadAsync(buffer, have, buffer.Length - have).ConfigureAwait(false); + if (read <= 0) break; + have += read; + + // drain every complete frame currently buffered, then compact what is left + var consumed = 0; + while (consumed < have) + { + var state = default(RespScanState); + if (!state.TryRead(buffer.AsSpan(consumed, have - consumed), out var frameLength)) break; + + Dispatch(buffer.AsSpan(consumed, frameLength)); + consumed += frameLength; + } + + if (consumed > 0) + { + Buffer.BlockCopy(buffer, consumed, buffer, 0, have - consumed); + have -= consumed; + } + } + } + catch (Exception ex) when (!_shutdown.IsCancellationRequested) + { + while (_pending.TryDequeue(out var stranded)) stranded.TrySetException(ex); + } + } + + /// Route one frame: an invalidation push feeds the cache, anything else answers a request. + /// + /// The first byte IS the prefix, so this needs no parsing to decide - the same reasoning + /// RespClientCache.IsCacheableReply uses for errors. Attributes are the only construct that can + /// precede a value, and a push is never behind one. + /// + private void Dispatch(ReadOnlySpan frame) + { + if (!frame.IsEmpty && (RespPrefix)frame[0] == RespPrefix.Push) + { + if (TryInvalidate(frame)) return; + + // A push that is NOT an invalidation must not be mistaken for a reply - pub/sub delivery is + // out-of-band and belongs to nobody's request. This is precisely the discrimination the real + // pipeline has to get right: it currently drops invalidations because it expects pub/sub shape + // (message / channel / payload, all strings) and an invalidation's second element is an array + // or a null. + if (IsDelivery(frame)) return; + + // ...but subscribe/unsubscribe confirmations ARE the reply to a command, despite being typed as + // pushes in RESP3, so they fall through to the pending queue. + } + + if (_pending.TryDequeue(out var completion)) completion.TrySetResult(frame.ToArray()); + } + + /// Is this push an out-of-band pub/sub delivery, rather than a reply to something we sent? + private static bool IsDelivery(ReadOnlySpan frame) + { + var reader = new RespReader(frame); + if (!reader.TryMoveNext(checkError: false) || !reader.TryMoveNext(false)) return false; + return reader.Is("message"u8) || reader.Is("pmessage"u8) || reader.Is("smessage"u8); + } + + private bool TryInvalidate(ReadOnlySpan frame) + { + var reader = new RespReader(frame); + if (!reader.TryMoveNext(checkError: false) || reader.Prefix != RespPrefix.Push) return false; + if (!reader.TryMoveNext(false) || !reader.Is("invalidate"u8)) return false; // e.g. a pub/sub push + if (!reader.TryMoveNext(false)) return false; + + // a null payload is FLUSHALL/FLUSHDB - "everything you have is gone", not "nothing changed" + if (reader.IsNull) + { + Interlocked.Increment(ref _invalidations); + Interlocked.Increment(ref _flushes); + _cache.OnFlush(); + return true; + } + + // ONE push can name several keys: MSET a b c arrives as a single push with a 3-element array + var count = reader.AggregateLength(); + Interlocked.Increment(ref _invalidations); + for (var i = 0; i < count; i++) + { + if (!reader.TryMoveNext(false) || !reader.TryGetSpan(out var key)) break; + Interlocked.Increment(ref _keysInvalidated); + _cache.OnInvalidate(key); // allocation-free: the key never leaves this span + } + + return true; + } + + public void Dispose() + { + _shutdown.Cancel(); + try + { + _stream.Dispose(); + _socket.Dispose(); + } + catch + { + // a harness tearing down; nothing useful to do + } + + _shutdown.Dispose(); + } +} diff --git a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs new file mode 100644 index 000000000..f6bc0d391 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Client-side caching end to end against a real server: read, cache, have somebody else write, and check +/// the invalidation actually arrives and evicts. +/// +/// +/// This is the half of the design that was resting on reasoning rather than evidence. Everything else about +/// the cache is tested against fakes, which can prove the logic but not that a server's invalidation reaches +/// us, nor that the key bytes it names match the ones we recorded when we wrote the command. +/// +public class RespTrackingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + /// + /// Wait until invalidation traffic goes quiet, so a test can take a baseline it can trust. + /// + /// + /// Our own writes come back to us. NOLOOP is off, so a setup SET produces an + /// invalidation push for the key we just wrote, arriving at some point after the reply. A baseline + /// captured before that lands makes any delta assertion racy - which is exactly how these tests failed + /// intermittently before this existed. See design notes 6.13 on why NOLOOP is not simply switched on. + /// + private static async Task SettleAsync(TrackingExecutor executor, int quietMillis = 200) + { + var seen = -1; + while (seen != executor.Invalidations) + { + seen = executor.Invalidations; + await Task.Delay(quietMillis); + } + } + + private static async Task WaitFor(Func condition, int millis = 2000) + { + var watch = Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < millis) + { + if (condition()) return true; + await Task.Delay(15); + } + + return condition(); + } + + /// + /// A tracking connection scoped to this test's own key prefix. + /// + /// + /// The prefix is not decoration. Under BCAST with no prefix, this connection is told about + /// every key every other test in the suite touches - which is the chattiness cost of broadcasting, made + /// concrete. Any assertion counting invalidations is then counting the whole suite's traffic, and these + /// tests duly failed at random until the prefix went in. It also exercises PREFIX for free. + /// + private async Task<(TrackingExecutor Executor, RespClientCache Cache, RespContext Context)> Tracked(string prefix) + { + var cache = new RespClientCache(); + TrackingExecutor executor; + try + { + executor = await TrackingExecutor.ConnectAsync( + TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort, cache, prefix); + } + catch (Exception ex) + { + cache.Dispose(); + Assert.Skip("Unable to connect to server: " + ex.Message); + throw; + } + + return (executor, cache, new RespContext().WithExecutor(executor).WithCache(cache)); + } + + [Fact] + public async Task AThirdPartyWriteInvalidatesWhatWeCached() + { + var (executor, cache, context) = await Tracked(Me()); + using var _ = executor; + using var __ = cache; + + var key = Me(); + await executor.CommandAsync("SET", key, "first"); + await SettleAsync(executor); // let our own write's echo land before we cache anything + + Assert.Equal("first", await context.Strings.Get(key)); + Assert.Equal(1, cache.Count); + + // served from cache: the server never sees the second read + Assert.Equal("first", await context.Strings.Get(key)); + Assert.Equal(1, cache.Count); + + // somebody else changes it - a different connection entirely + await using var other = Create(); + await other.GetDatabase().StringSetAsync(key, "second"); + + Assert.True(await WaitFor(() => executor.KeysInvalidated > 0), "the invalidation never arrived"); + + // invalidation only STAMPS - the entry stays resident until a sweep, so residency is the wrong + // thing to assert; what matters is that it is no longer readable + Assert.Equal(1, cache.Sweep()); + Assert.Equal(0, cache.Count); + + // and the next read gets the new value, from the server + Assert.Equal("second", await context.Strings.Get(key)); + } + + [Fact] + public async Task TheKeyBytesTheServerSendsAreTheOnesWeRecorded() + { + // the assumption underneath all of this: the cache records the key exactly as rendered, and the + // server invalidates by the name it saw. Non-ASCII is where a mismatch would show up first. + var (executor, cache, context) = await Tracked(Me()); + using var _ = executor; + using var __ = cache; + + var key = Me() + ":éü中文"; + await executor.CommandAsync("SET", key, "value"); + await SettleAsync(executor); + + Assert.Equal("value", await context.Strings.Get(key)); + Assert.Equal(1, cache.Count); + + await using var other = Create(); + await other.GetDatabase().StringSetAsync(key, "changed"); + + Assert.True(await WaitFor(() => executor.KeysInvalidated > 0), "no invalidation arrived at all"); + + // the real assertion: the server's key bytes matched ours, so the stamp landed on OUR entry + Assert.Equal(1, cache.Sweep()); + Assert.Equal("changed", await context.Strings.Get(key)); + } + + [Fact] + public async Task OneWriteTouchingSeveralKeysInvalidatesAllOfThem() + { + var (executor, cache, context) = await Tracked(Me()); + using var _ = executor; + using var __ = cache; + + var prefix = Me(); + string A = prefix + ":a", B = prefix + ":b"; + await executor.CommandAsync("MSET", A, "1", B, "2"); + await SettleAsync(executor); + var seenBefore = executor.KeysInvalidated; + + Assert.Equal("1", await context.Strings.Get(A)); + Assert.Equal("2", await context.Strings.Get(B)); + Assert.Equal(2, cache.Count); + + await using var other = Create(); + await other.GetDatabase().ExecuteAsync("MSET", A, "x", B, "y"); + + // one push, several keys - if the handler read only the first, one entry would survive the sweep + Assert.True( + await WaitFor(() => executor.KeysInvalidated - seenBefore >= 2), + "the push did not name both keys"); + Assert.Equal(2, cache.Sweep()); + } + + [Fact] + public async Task APubSubPushIsNotMistakenForAnInvalidation() + { + // Invalidations and pub/sub deliveries are both RESP3 pushes, and telling them apart is the whole + // of the discrimination the real pipeline has to add. Getting it backwards is not a no-op: a + // channel name would be read as a key list, or a delivery would be handed back as somebody's reply. + var (executor, cache, context) = await Tracked(Me()); + using var _ = executor; + using var __ = cache; + + var key = Me(); + await executor.CommandAsync("SET", key, "value"); + await SettleAsync(executor); + Assert.Equal("value", await context.Strings.Get(key)); + + var channel = Me() + ":channel"; + await executor.CommandAsync("SUBSCRIBE", channel); + + var before = executor.Invalidations; + + await using var other = Create(); + await other.GetSubscriber().PublishAsync(RedisChannel.Literal(channel), "hello"); + + // give the delivery time to arrive and be mis-handled, if it is going to be + await Task.Delay(300); + + Assert.Equal(before, executor.Invalidations); // the delivery was not counted as an invalidation + Assert.Equal(1, cache.Count); // ...and our entry is untouched + Assert.Equal("value", await context.Strings.Get(key)); + } + + /// A database index this suite does not otherwise use, so flushing it disturbs nobody. + /// + /// The flush test is the only destructive one here, and FLUSHDB on the shared primary would wipe + /// the database out from under every test running concurrently - which is exactly what it did the first + /// time. Tracking is database-agnostic (the server keeps "a single keys namespace, not divided by + /// database numbers"), so the push arrives regardless of which database was flushed, and confining the + /// damage costs nothing. + /// + private const int ScratchDatabase = 9; + + [Fact] + public async Task AFlushDropsEverything() + { + var (executor, cache, context) = await Tracked(Me()); + using var _ = executor; + using var __ = cache; + + var key = Me(); + await executor.CommandAsync("SET", key, "value"); + await SettleAsync(executor); + Assert.Equal("value", await context.Strings.Get(key)); + Assert.Equal(1, cache.Count); + + // put something in the scratch database, then flush ONLY that one + await using var other = Create(allowAdmin: true); + await other.GetDatabase(ScratchDatabase).StringSetAsync(key, "scratch"); + await other.GetServer(TestConfig.Current.PrimaryServerAndPort).FlushDatabaseAsync(ScratchDatabase); + + Assert.True(await WaitFor(() => executor.Flushes > 0), "the flush arrived as a key list rather than a null"); + Assert.Equal(1, cache.Sweep()); + } +} From 1f8e3cbd66e1156e7d276500f1398f4aabb1e7ae Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 15:59:45 +0100 Subject: [PATCH 095/360] Classify pushes the way the real connection does, and stop flaking "subscribe/unsubscribe confirmations ARE command replies" was too strong: they CAN be. The same shape arrives unsolicited (RESET, server-side teardown, shard migration), and UNSUBSCRIBE with no arguments answers one command with N pushes - or none, if there were no subscriptions. So correlation is not one-to-one and cannot be decided from the frame; it needs subscription state. The library already knows this: OutOfBandResult has a third value, MatchToCommand, for exactly that. The harness was doing the dangerous thing with the rest: an unrecognised push fell through to the pending queue, which would answer somebody's request with it and desynchronise every reply after. It now mirrors PhysicalConnection.OnOutOfBand - invalidate, delivery, match-to-command, and DROP anything unknown. 6.13 now also records what the production integration needs, which is two things and both known rather than guessed: a PushKind member for "invalidate", and handling it BEFORE the TryMoveNextString gate - that gate demands an inline string second element because for pub/sub it is the channel, whereas an invalidation's is an array or a null, so adding the enum member alone would still fall out as NotRecognized. Test flakiness, three separate causes, all mine: - hard-coded scratch database 9 collided with TestConfig.GetDedicatedDB, which is a monotonic counter and reaches 9. Now uses the allocator. - SettleAsync concluded "quiet" before the echo had arrived at all under load; it now waits FOR the expected echo, then for quiet. - the pub/sub test asserted a global negative ("no invalidation arrived"), which any unrelated traffic breaks. It now asserts the positive: the delivery was classified as a delivery. And the finding behind that last one, verified against the server: a flush CANNOT be scoped away. PREFIX correctly ignores non-matching writes, but a FLUSHDB on an unrelated database still arrives as `invalidate _`, because a flush names no keys. One FLUSHDB by anyone empties every tracking client's cache - so a prefix bounds key traffic, not flushes. --- design/interpolated-resp-writer.md | 46 +++++++++-- .../Helpers/TrackingExecutor.cs | 82 +++++++++++++++---- .../RespTrackingTests.cs | 72 +++++++++------- 3 files changed, 149 insertions(+), 51 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 4ab5281c4..fbde39d65 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -1975,12 +1975,48 @@ our own SET +OK\r\n followed by the same push (NOLOOP off; reply first, t - **One push can name several keys.** A handler that reads only the first leaves entries live. - **Key expiry invalidates too**, not only explicit writes — confirmed by watching a `PX 150` key. - `BCAST` really does report keys we never read, and `PREFIX` filters exactly as documented. +- **A flush cannot be scoped away.** `PREFIX want:` correctly ignores a write to `other:key`, but a + `FLUSHDB` on an entirely different database still arrives as `>2 invalidate _`. A flush names no keys, so + there is nothing for a prefix to filter and nothing to attribute to a database. One `FLUSHDB` by anyone, + anywhere, empties every tracking client's cache. That is correct, and worth knowing before someone + reasons that a narrow prefix bounds their exposure - it bounds key traffic, not flushes. -**Telling an invalidation from a pub/sub delivery is the whole of the discrimination the real pipeline -needs to add**, and getting it backwards is not a no-op in either direction: a channel name read as a key -list, or a delivery handed back as somebody's reply. Note the third case — `subscribe`/`unsubscribe` -confirmations are *also* typed as pushes in RESP3 but *are* the reply to a command, so "push means -out-of-band" is too simple. +**Telling an invalidation from a pub/sub delivery is the discrimination the real pipeline needs to add**, +and getting it backwards is not a no-op in either direction: a channel name read as a key list, or a +delivery handed back as somebody's reply. + +And "push means out-of-band" is too simple, in a way that is genuinely unpleasant. +`subscribe`/`unsubscribe` confirmations are typed as pushes but **can be** the reply to a command — *can +be*, not *are*. The same shape arrives unsolicited (`RESET`, server-side teardown, shard migration), and +`UNSUBSCRIBE` with no arguments answers **one** command with **N** pushes, or none at all if there were no +subscriptions. So the correlation is not one-to-one, and **cannot be decided by inspecting the frame** — it +needs subscription state. + +The library already has this: `PhysicalConnection.OutOfBandResult` has a third value, `MatchToCommand`, +for exactly that case, and its policy for anything unknown is worth copying verbatim: + +> *"a RESP3 push frame is out-of-band by definition; if we don't recognize it (newer server, or a feature +> we don't implement) we drop it - matching it to a pending command would desynchronize the entire response +> stream"* + +Which is also precisely why invalidations are dropped today: they are simply not recognised. + +#### What the production integration actually needs + +Two changes, both small, and now known rather than guessed: + +1. **`PushKind` gains `[AsciiHash("invalidate")] Invalidate`** (`PhysicalConnection.Read.cs`). One line; the + generator does the parsing. +2. **It must be handled *before* the channel gate.** The existing flow does: + + ```csharp + if (kind is PushKind.None || !TryMoveNextString(ref reader)) return OutOfBandResult.NotRecognized; + ``` + + `TryMoveNextString` requires the second element to be an inline `BulkString`/`SimpleString`, because for + pub/sub the second element is always the channel. An invalidation's second element is an **array** or a + **null**, so adding the enum member alone changes nothing — it would still fall out here as + `NotRecognized` and be dropped. Invalidation has to branch off ahead of that gate and return `Handled`. Proven end to end by `RespTrackingTests` against a real server, through a dedicated RESP3 `BCAST` connection (`TrackingExecutor`): a third party's write evicts what we cached, non-ASCII key bytes match, diff --git a/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs index feb0279fd..df095db87 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs @@ -38,7 +38,7 @@ internal sealed class TrackingExecutor : IRespExecutor, IDisposable private readonly ConcurrentQueue> _pending = new(); private readonly CancellationTokenSource _shutdown = new(); - private int _invalidations, _flushes, _keysInvalidated; + private int _invalidations, _flushes, _keysInvalidated, _deliveries; /// Invalidation pushes received. internal int Invalidations => Volatile.Read(ref _invalidations); @@ -49,6 +49,9 @@ internal sealed class TrackingExecutor : IRespExecutor, IDisposable /// Flush pushes received (the null payload). internal int Flushes => Volatile.Read(ref _flushes); + /// Pub/sub deliveries recognised as such, and therefore NOT mistaken for invalidations. + internal int Deliveries => Volatile.Read(ref _deliveries); + public int Database => 0; private TrackingExecutor(Socket socket, RespClientCache cache) @@ -172,39 +175,84 @@ private async Task ReadLoopAsync() /// RespClientCache.IsCacheableReply uses for errors. Attributes are the only construct that can /// precede a value, and a push is never behind one. /// + /// What a push frame turns out to be. Mirrors PhysicalConnection's OutOfBandResult. + private enum PushClass + { + /// Unknown to us. DROP it - see . + Unrecognized, + + /// An invalidation; feed the cache. + Invalidate, + + /// Out-of-band pub/sub delivery; belongs to no request. + Delivery, + + /// A subscribe/unsubscribe confirmation, which can be the reply to a command. + MatchToCommand, + } + private void Dispatch(ReadOnlySpan frame) { if (!frame.IsEmpty && (RespPrefix)frame[0] == RespPrefix.Push) { - if (TryInvalidate(frame)) return; - - // A push that is NOT an invalidation must not be mistaken for a reply - pub/sub delivery is - // out-of-band and belongs to nobody's request. This is precisely the discrimination the real - // pipeline has to get right: it currently drops invalidations because it expects pub/sub shape - // (message / channel / payload, all strings) and an invalidation's second element is an array - // or a null. - if (IsDelivery(frame)) return; - - // ...but subscribe/unsubscribe confirmations ARE the reply to a command, despite being typed as - // pushes in RESP3, so they fall through to the pending queue. + switch (Classify(frame)) + { + case PushClass.Invalidate: + TryInvalidate(frame); + return; + + case PushClass.MatchToCommand: + break; // falls through to the pending queue below + + case PushClass.Delivery: + Interlocked.Increment(ref _deliveries); + return; + + default: + // Something we do not know. DROPPING the unknown is the important half: + // a RESP3 push is out-of-band by definition, so handing an unrecognised one to the + // pending queue would answer somebody's request with it and desynchronise every reply + // after it. PhysicalConnection.OnOutOfBand takes exactly this line. + return; + } } if (_pending.TryDequeue(out var completion)) completion.TrySetResult(frame.ToArray()); } - /// Is this push an out-of-band pub/sub delivery, rather than a reply to something we sent? - private static bool IsDelivery(ReadOnlySpan frame) + /// + /// Classify a push by its first element. + /// + /// + /// Note that is "can be", not "is": the same + /// subscribe/unsubscribe shape also arrives unsolicited (RESET, server-side teardown, shard + /// migration), and UNSUBSCRIBE with no arguments answers one command with N pushes - or + /// none at all, if there were no subscriptions. So correlation is not one-to-one and cannot be decided + /// from the frame. This harness gets away with the naive version because it issues exactly one + /// SUBSCRIBE and awaits exactly one reply; the real connection tracks subscription state instead. + /// + private static PushClass Classify(ReadOnlySpan frame) { var reader = new RespReader(frame); - if (!reader.TryMoveNext(checkError: false) || !reader.TryMoveNext(false)) return false; - return reader.Is("message"u8) || reader.Is("pmessage"u8) || reader.Is("smessage"u8); + if (!reader.TryMoveNext(checkError: false) || !reader.TryMoveNext(false)) return PushClass.Unrecognized; + + if (reader.Is("invalidate"u8)) return PushClass.Invalidate; + if (reader.Is("message"u8) || reader.Is("pmessage"u8) || reader.Is("smessage"u8)) return PushClass.Delivery; + if (reader.Is("subscribe"u8) || reader.Is("unsubscribe"u8) + || reader.Is("psubscribe"u8) || reader.Is("punsubscribe"u8) + || reader.Is("ssubscribe"u8) || reader.Is("sunsubscribe"u8)) + { + return PushClass.MatchToCommand; + } + + return PushClass.Unrecognized; } private bool TryInvalidate(ReadOnlySpan frame) { var reader = new RespReader(frame); if (!reader.TryMoveNext(checkError: false) || reader.Prefix != RespPrefix.Push) return false; - if (!reader.TryMoveNext(false) || !reader.Is("invalidate"u8)) return false; // e.g. a pub/sub push + if (!reader.TryMoveNext(false) || !reader.Is("invalidate"u8)) return false; if (!reader.TryMoveNext(false)) return false; // a null payload is FLUSHALL/FLUSHDB - "everything you have is gone", not "nothing changed" diff --git a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs index f6bc0d391..aacd1200f 100644 --- a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs +++ b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs @@ -37,6 +37,27 @@ private static async Task SettleAsync(TrackingExecutor executor, int quietMillis } } + /// + /// Run a write of our own and wait until its echo has actually arrived and traffic has gone quiet. + /// + /// + /// Waiting for quiet is not enough by itself, and this is the subtle part: under load the echo may not + /// have arrived at all yet, so two consecutive samples read the same number and "settled" is + /// concluded before the push lands - which then turns up later and spoils whatever baseline the test + /// took. So: wait for the echo we know is coming, and only then wait for quiet. + /// + private static async Task WriteAndSettleAsync(TrackingExecutor executor, params string[] command) + { + var before = executor.Invalidations; + await executor.CommandAsync(command); + + Assert.True( + await WaitFor(() => executor.Invalidations > before), + "our own write did not echo back - is NOLOOP on?"); + + await SettleAsync(executor); + } + private static async Task WaitFor(Func condition, int millis = 2000) { var watch = Stopwatch.StartNew(); @@ -85,8 +106,7 @@ public async Task AThirdPartyWriteInvalidatesWhatWeCached() using var __ = cache; var key = Me(); - await executor.CommandAsync("SET", key, "first"); - await SettleAsync(executor); // let our own write's echo land before we cache anything + await WriteAndSettleAsync(executor, "SET", key, "first"); Assert.Equal("first", await context.Strings.Get(key)); Assert.Equal(1, cache.Count); @@ -120,8 +140,7 @@ public async Task TheKeyBytesTheServerSendsAreTheOnesWeRecorded() using var __ = cache; var key = Me() + ":éü中文"; - await executor.CommandAsync("SET", key, "value"); - await SettleAsync(executor); + await WriteAndSettleAsync(executor, "SET", key, "value"); Assert.Equal("value", await context.Strings.Get(key)); Assert.Equal(1, cache.Count); @@ -145,8 +164,7 @@ public async Task OneWriteTouchingSeveralKeysInvalidatesAllOfThem() var prefix = Me(); string A = prefix + ":a", B = prefix + ":b"; - await executor.CommandAsync("MSET", A, "1", B, "2"); - await SettleAsync(executor); + await WriteAndSettleAsync(executor, "MSET", A, "1", B, "2"); var seenBefore = executor.KeysInvalidated; Assert.Equal("1", await context.Strings.Get(A)); @@ -174,36 +192,28 @@ public async Task APubSubPushIsNotMistakenForAnInvalidation() using var __ = cache; var key = Me(); - await executor.CommandAsync("SET", key, "value"); - await SettleAsync(executor); + await WriteAndSettleAsync(executor, "SET", key, "value"); Assert.Equal("value", await context.Strings.Get(key)); var channel = Me() + ":channel"; await executor.CommandAsync("SUBSCRIBE", channel); - var before = executor.Invalidations; - await using var other = Create(); await other.GetSubscriber().PublishAsync(RedisChannel.Literal(channel), "hello"); - // give the delivery time to arrive and be mis-handled, if it is going to be - await Task.Delay(300); + // Assert the POSITIVE - that the delivery was classified as a delivery. Asserting the negative + // ("no invalidation arrived") looks equivalent and is not: it is a claim about a global counter, + // so any unrelated traffic on this connection fails it, and it flaked under suite load. + Assert.True(await WaitFor(() => executor.Deliveries > 0), "the pub/sub delivery never arrived"); - Assert.Equal(before, executor.Invalidations); // the delivery was not counted as an invalidation - Assert.Equal(1, cache.Count); // ...and our entry is untouched + // Deliberately NOT asserting that our cached entry survived. That looks like the natural companion + // assertion and it is not testable on a shared server: a FLUSHDB by ANY concurrent test, on ANY + // database, sends every tracking client an unfilterable `invalidate null` - PREFIX cannot scope a + // flush, because a flush names no keys. Verified against the server. So the entry legitimately + // disappears at random here, and asserting otherwise tests the suite's scheduling, not the code. Assert.Equal("value", await context.Strings.Get(key)); } - /// A database index this suite does not otherwise use, so flushing it disturbs nobody. - /// - /// The flush test is the only destructive one here, and FLUSHDB on the shared primary would wipe - /// the database out from under every test running concurrently - which is exactly what it did the first - /// time. Tracking is database-agnostic (the server keeps "a single keys namespace, not divided by - /// database numbers"), so the push arrives regardless of which database was flushed, and confining the - /// damage costs nothing. - /// - private const int ScratchDatabase = 9; - [Fact] public async Task AFlushDropsEverything() { @@ -212,15 +222,19 @@ public async Task AFlushDropsEverything() using var __ = cache; var key = Me(); - await executor.CommandAsync("SET", key, "value"); - await SettleAsync(executor); + await WriteAndSettleAsync(executor, "SET", key, "value"); Assert.Equal("value", await context.Strings.Get(key)); Assert.Equal(1, cache.Count); - // put something in the scratch database, then flush ONLY that one + // The only destructive test here. FLUSHDB on the shared primary would wipe the database out from + // under every concurrently-running test - which is exactly what it did the first time - so it goes + // to a database this suite hands out for the purpose. Hard-coding an index is not good enough + // either: GetDedicatedDB is a monotonic counter, so a "surely nobody uses 9" eventually collides + // with whoever gets 9. Tracking is database-agnostic, so the push arrives regardless. await using var other = Create(allowAdmin: true); - await other.GetDatabase(ScratchDatabase).StringSetAsync(key, "scratch"); - await other.GetServer(TestConfig.Current.PrimaryServerAndPort).FlushDatabaseAsync(ScratchDatabase); + var scratch = TestConfig.GetDedicatedDB(other); + await other.GetDatabase(scratch).StringSetAsync(key, "scratch"); + await other.GetServer(TestConfig.Current.PrimaryServerAndPort).FlushDatabaseAsync(scratch); Assert.True(await WaitFor(() => executor.Flushes > 0), "the flush arrived as a key list rather than a null"); Assert.Equal(1, cache.Sweep()); From 9cf7da775f0ba8f65b79280a746683eec1e321e5 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:13:56 +0100 Subject: [PATCH 096/360] CachePolicy, and a finite entry lifetime Fixes the worst default available: without a lifetime, an un-invalidated entry is served FOREVER. The absence of a TTL is not the absence of a policy, it is TTL = infinity. CachePolicy is a sealed class held once by the cache - deployment configuration rather than a per-call argument - with a one minute default. That number is a safety bound, not a tuning knob: it covers a MISSED invalidation (a connection blip nobody noticed, or a bug), and disconnect-flush plus keepalive detects a real disconnect far sooner, so a minute bounds the damage when detection itself fails. RespContext.WithMaxCacheAge is the single per-call knob, because freshness tolerance is the one thing that genuinely varies by caller - and the one thing that could never be added to IDatabase without a binary break, which is the context-as-extension-point argument paying off again. Two properties worth stating because both are easy to get backwards: - age is checked on READ, never stamped on store, so ONE entry serves callers with different tolerances rather than being duplicated per lifetime. There is a test that asserts exactly that: two contexts, one send, one entry. - a context NARROWS, never widens - the effective limit is Min(policy, caller), so nobody can ask for staler than the deployment permits. Clock is Stopwatch.GetTimestamp(): Environment.TickCount64 does not exist on net461/netstandard2.0, and 32-bit TickCount wraps every ~49 days. New Expired counter, deliberately separate from invalidation: nobody told us the entry was wrong, we stopped trusting it. High against Stored means either the lifetime is too short, or invalidation is doing nothing for you. Mutation-tested: nothing expiring, and ignoring the caller's requirement. --- .claude/worktrees/strings-surface | 1 + design/interpolated-resp-writer.md | 24 ++++ .../Interpolated/CachePolicy.cs | 92 +++++++++++++ .../Interpolated/RespClientCache.cs | 63 ++++++++- .../Interpolated/RespContext.cs | 19 +++ .../Interpolated/RespExecutor.cs | 12 +- .../PublicAPI/PublicAPI.Unshipped.txt | 13 +- .../RespCacheLifetimeTests.cs | 130 ++++++++++++++++++ 8 files changed, 344 insertions(+), 10 deletions(-) create mode 160000 .claude/worktrees/strings-surface create mode 100644 src/StackExchange.Redis/Interpolated/CachePolicy.cs create mode 100644 tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs diff --git a/.claude/worktrees/strings-surface b/.claude/worktrees/strings-surface new file mode 160000 index 000000000..1f8e3cbd6 --- /dev/null +++ b/.claude/worktrees/strings-surface @@ -0,0 +1 @@ +Subproject commit 1f8e3cbd66e1156e7d276500f1398f4aabb1e7ae diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index fbde39d65..aedcdd63f 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2216,6 +2216,30 @@ given sync is deprioritised; it closes when the executor grows a synchronous wai `Coalesced` counts the stampedes that did not happen, and is the counterpart to `RedundantFills`. The two together are the useful signal: coalesced rising while redundant stays flat is the shape you want. +#### Built: CachePolicy and a finite lifetime + +`CachePolicy` is a sealed class held once by the cache - deployment-level configuration, not a per-call +argument. `RespContext.WithMaxCacheAge` is the single per-call knob, because freshness tolerance is the one +thing that genuinely varies by caller *and* the one thing that could never be added to `IDatabase` without a +binary break. + +Three properties that took a wrong turn first and are worth stating flatly: + +- **The default lifetime is finite** (one minute). See §6.14: the absence of a lifetime is not the absence + of a policy, it is `TTL = infinity`. +- **Age is checked on read, never stamped on store.** One entry serves callers with different tolerances; + a test asserts exactly that (`OneEntryServesCallersWithDifferentTolerances` - one send, one entry, two + contexts). +- **A context narrows, never widens.** The effective limit is `Min(policy, caller)`, so a caller can ask for + fresher but never for staler than the deployment allows. + +Clock is `Stopwatch.GetTimestamp()`: `Environment.TickCount64` does not exist on `net461`/`netstandard2.0`, +and the 32-bit `TickCount` wraps every ~49 days. + +`Expired` counts hits refused for age, and is deliberately separate from invalidation - nobody told us the +entry was wrong, we just stopped trusting it. A high count against `Stored` means either the lifetime is +shorter than the data's useful life, or invalidation is doing nothing for you. + #### Consequence for the context Soft window, hard TTL, invalidation-SWR on/off, staleness cap — four knobs, all contextual for the reasons diff --git a/src/StackExchange.Redis/Interpolated/CachePolicy.cs b/src/StackExchange.Redis/Interpolated/CachePolicy.cs new file mode 100644 index 000000000..14abbe588 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/CachePolicy.cs @@ -0,0 +1,92 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. How a client-side cache behaves: how long an entry may be served, and what to do + /// as it ages. + /// + /// + /// + /// Deployment-level configuration, held once by the cache rather than passed per call. The one thing + /// that genuinely varies per caller is how stale an answer they will accept, and that rides on the + /// context instead - see . Splitting them that way matches + /// where each decision actually lives: the tracking mode is a fact about the connection, the default + /// lifetime is a fact about the deployment, and freshness tolerance is a fact about the call. + /// + /// + /// It also keeps at its 48 bytes: a context carries a reference to shared + /// policy plus at most one override, rather than a field per knob. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class CachePolicy + { + /// The default policy, used when none is given. + public static CachePolicy Default { get; } = new(); + + /// + /// The longest an entry may be served after it was fetched, regardless of invalidation. + /// + /// + /// + /// A safety bound, not a tuning knob, and it must be finite. There is no such thing as "no + /// TTL policy" - the absence of a lifetime is a policy, and it is infinity. An entry that is never + /// invalidated and never expires is permanently stale, which is strictly worse than being + /// briefly over-stale. The Redis documentation makes the same point: "Putting a max TTL on every + /// key is a good idea, even if it has no TTL. This protects against bugs or connection issues that + /// would make the client have old data in the local copy." + /// + /// + /// What it protects against is a missed invalidation - a connection blip nobody noticed, or a + /// bug. Flushing on disconnect plus keepalive detects a real disconnect within seconds to tens of + /// seconds, so the default of one minute is comfortably longer than detection while still bounding + /// the damage when detection itself fails. + /// + /// + public TimeSpan TimeToLive { get; init; } = TimeSpan.FromMinutes(1); + + /// Whether this policy permits caching at all. + public bool Enabled { get; init; } = true; + + /// as a tick count. + /// + /// rather than Environment.TickCount64, which does not + /// exist on net461/netstandard2.0 - and whose 32-bit form wraps every ~49 days, which + /// is exactly the kind of thing that bites once a quarter. + /// + internal long TimeToLiveTicks => ToTicks(TimeToLive); + + internal static long ToTicks(TimeSpan value) + => value == TimeSpan.MaxValue + ? long.MaxValue + : (long)(value.TotalSeconds * Stopwatch.Frequency); + + /// Whether an entry filled at has outlived . + internal static bool IsOlderThan(long filledAt, long ticks) + => ticks != long.MaxValue && Stopwatch.GetTimestamp() - filledAt > ticks; + } + + /// + /// EXPERIMENTAL SPIKE. A per-context override of how stale an answer the caller will accept. + /// + /// + /// The one piece of cache configuration that cannot be added to the existing surface: + /// IDatabase.StringGet cannot grow a parameter without a binary break, whereas the context + /// reaches every command without touching a single signature. + /// + /// Applied when the entry is read, never stamped when it is stored - the entry is shared, so one + /// copy has to serve callers with different tolerances. Stamping at store time would force identical + /// replies to be cached once per distinct lifetime, which is the opposite of what a shared cache is for. + /// + /// + internal sealed class MaxCacheAgeService(TimeSpan maxAge) + { + internal TimeSpan MaxAge { get; } = maxAge; + + internal long Ticks { get; } = CachePolicy.ToTicks(maxAge); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index bc75bdbfd..17150d098 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; @@ -55,10 +56,32 @@ public sealed class RespClientCache : IDisposable private long _redundantFills; private long _refusedError; private long _coalesced; + private long _expired; /// Create a cache. + /// How entries behave; when null. /// Initial size hint for the tracked-key table. - public RespClientCache(int keyCapacity = 256) => _keys = new RespKeyTable(keyCapacity); + /// + /// One constructor rather than an overload pair: two constructors both carrying optional parameters + /// is ambiguous for callers, and the analyzers say so (RS0026). Named arguments cover the cases an + /// overload would have. + /// + public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) + { + Policy = policy ?? CachePolicy.Default; + _keys = new RespKeyTable(keyCapacity); + } + + /// How entries in this cache behave. + public CachePolicy Policy { get; } + + /// Hits refused because the entry had outlived its lifetime. + /// + /// Distinct from an invalidation: nobody told us this was wrong, we simply stopped trusting it. A + /// high count relative to means the lifetime is shorter than the useful life of + /// the data - or, if invalidation is working, that it is doing nothing for you. + /// + public long Expired => Volatile.Read(ref _expired); /// The number of cached responses, including any not yet swept after invalidation. public int Count => _entries.Count; @@ -151,6 +174,29 @@ public sealed class RespClientCache : IDisposable /// the parse is done. /// public bool TryGet(in RespRequest frame, int database, [NotNullWhen(true)] out RespPayload? payload) + => TryGet(in frame, database, long.MaxValue, out payload); + + /// Look for a cached response, subject to a freshness requirement. + /// The rendered request. + /// The database the request ran against. + /// + /// The caller's own freshness requirement, from ; + /// when they did not state one. + /// + /// The cached reply, retained. + /// + /// + /// Age is checked on read, against the stricter of the policy's lifetime and the caller's + /// requirement - never stamped when the entry was stored. The entry is shared, so one copy serves + /// callers with different tolerances; stamping at store time would force identical replies to be + /// cached once per distinct lifetime, which is the opposite of what a shared cache is for. + /// + /// + /// An expired entry is left resident rather than removed, exactly as an invalidated one is: this is + /// a read path, and is where entries go. + /// + /// + public bool TryGet(in RespRequest frame, int database, long maxAgeTicks, [NotNullWhen(true)] out RespPayload? payload) { if (_entries.TryGetValue(new EntryKey(frame, database), out var entry) && entry.IsValid @@ -160,8 +206,14 @@ public bool TryGet(in RespRequest frame, int database, [NotNullWhen(true)] out R // let one stale read through the door it had already closed if (entry.IsValid) { - payload = entry.Payload; - return true; + var limit = Math.Min(Policy.TimeToLiveTicks, maxAgeTicks); + if (!CachePolicy.IsOlderThan(entry.FilledAt, limit)) + { + payload = entry.Payload; + return true; + } + + Interlocked.Increment(ref _expired); } entry.Payload.Release(); @@ -279,7 +331,7 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, /// The wait yields no value - the caller re-probes the cache afterwards. Handing the leader's /// payload across is the obvious design and it is worse: the payload is reference-counted, so a /// waiter resuming after the leader released its reference would have to be handed a dead buffer or - /// a racily-retained one. Re-probing reuses , whose retain-and-recheck is + /// a racily-retained one. Re-probing reuses TryGet, whose retain-and-recheck is /// already correct, and gives the right answer for free when the leader's reply turned out not to be /// cacheable at all. /// @@ -598,6 +650,9 @@ private sealed class Entry(RespPayload payload, Dependency[] dependencies) { internal RespPayload Payload { get; } = payload; + /// When this entry was filled, for expiry. See . + internal long FilledAt { get; } = Stopwatch.GetTimestamp(); + internal bool IsValid => Dependency.AllValid(dependencies); } diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 07b143593..6546564a3 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -255,6 +255,25 @@ public RespContext WithServices(object? services) /// services include one. public RespContext WithCache(RespClientCache? cache) => WithServices(cache); + /// A context whose cached answers must be no older than . + /// The oldest answer this caller will accept. + /// + /// Narrows, never widens: the cache's own is a ceiling, and + /// this cannot raise it. So a caller can ask for fresher, never for staler than the deployment + /// allows. + /// + /// This is the one knob that belongs on the context rather than on the policy, because freshness + /// tolerance is a property of the call and not of the connection or the deployment - and it is the + /// one that could not be added to IDatabase at all without a binary break. + /// + /// + public RespContext WithMaxCacheAge(TimeSpan maxAge) + => WithServices(ServiceLink.Add(_services, new MaxCacheAgeService(maxAge))); + + /// The caller's freshness requirement, if they stated one. + internal long MaxCacheAgeTicks + => TryGetService(out var service) ? service.Ticks : long.MaxValue; + /// /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into the /// handler's constructor; that is how the handler reaches the command map, the prefixes, and the diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 44c08c8f7..edbd2c14c 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -119,7 +119,7 @@ public static TResult Send( // not get a cached answer either, not merely that this reply is not kept if (cache is not null && cache.PermitsCaching(flags)) { - if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) return cached; + if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, out var cached)) return cached; // NOTE: no in-flight wait here. Coalescing means waiting on someone else's Task, and doing // that from a synchronous caller is the sync-over-async problem this design avoids @@ -199,7 +199,7 @@ public static ValueTask SendAsync( if (cache is not null && cache.PermitsCaching(flags)) { - if (TryServeFromCache(executor, ref request, handler, cache, out var cached)) + if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, out var cached)) { return new ValueTask(cached); } @@ -208,7 +208,7 @@ public static ValueTask SendAsync( // copy. See design notes 6.15; this is the whole of the stampede fix at the call site. if (cache.TryAwaitInFlight(request.AsLookupKey(), executor.Database, out var pending)) { - return AwaitShared(executor, request.Detach(flags), pending, handler, cache, cancellationToken); + return AwaitShared(executor, request.Detach(flags), pending, handler, cache, context.MaxCacheAgeTicks, cancellationToken); } if (cache.TryBeginFill(ref request, executor.Database, flags, out var fill)) @@ -322,9 +322,10 @@ private static bool TryServeFromCache( ref RespFrame request, IRespHandler handler, RespClientCache cache, + long maxAgeTicks, [MaybeNullWhen(false)] out TResult result) { - if (!cache.TryGet(request.AsLookupKey(), executor.Database, out var hit)) + if (!cache.TryGet(request.AsLookupKey(), executor.Database, maxAgeTicks, out var hit)) { result = default; return false; @@ -398,6 +399,7 @@ private static async ValueTask AwaitShared( Task pending, IRespHandler handler, RespClientCache cache, + long maxAgeTicks, CancellationToken cancellationToken) { try @@ -405,7 +407,7 @@ private static async ValueTask AwaitShared( await pending.ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - if (cache.TryGet(owned, executor.Database, out var hit)) + if (cache.TryGet(owned, executor.Database, maxAgeTicks, out var hit)) { try { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1586a3d2e..d246cfe57 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -29,7 +29,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedError.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(int keyCapacity = 256) -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(StackExchange.Redis.Interpolated.CachePolicy? policy = null, int keyCapacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.Abandon() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void @@ -173,3 +173,14 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackExchange.Redis.CommandFlags flags, StackExchange.Redis.CommandFlags category) -> StackExchange.Redis.CommandFlags +[SER010]StackExchange.Redis.Interpolated.CachePolicy +[SER010]StackExchange.Redis.Interpolated.CachePolicy.CachePolicy() -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.Enabled.get -> bool +[SER010]StackExchange.Redis.Interpolated.CachePolicy.Enabled.init -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Expired.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Policy.get -> StackExchange.Redis.Interpolated.CachePolicy! +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool +[SER010]StackExchange.Redis.Interpolated.RespContext.WithMaxCacheAge(System.TimeSpan maxAge) -> StackExchange.Redis.Interpolated.RespContext +[SER010]static StackExchange.Redis.Interpolated.CachePolicy.Default.get -> StackExchange.Redis.Interpolated.CachePolicy! diff --git a/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs b/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs new file mode 100644 index 000000000..f74b24471 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Entry lifetime: the backstop that stops an un-invalidated entry being served forever, and the +/// per-caller freshness requirement that can ask for less than it. +/// +public class RespCacheLifetimeTests +{ + private sealed class CountingExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + internal int Sends { get; private set; } + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sends++; + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private const CommandFlags Readable = CommandFlags.CommandRetryReadOnly; + + private static ValueTask Get(RespContext context) + => context.SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", Readable); + + [Fact] + public async Task TheDefaultLifetimeIsFiniteRatherThanForever() + { + // the whole point: with no invalidation wired, an entry with no lifetime is PERMANENTLY stale, + // which is strictly worse than briefly over-stale + Assert.True(CachePolicy.Default.TimeToLive < TimeSpan.MaxValue); + Assert.True(CachePolicy.Default.TimeToLive > TimeSpan.Zero); + + // and a fresh entry is genuinely served from cache under it + using var cache = new RespClientCache(); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal("a", await Get(context)); + Assert.Equal(1, executor.Sends); + } + + [Fact] + public async Task AnExpiredEntryIsNotServed() + { + using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal("a", await Get(context)); // still fresh + Assert.Equal(1, executor.Sends); + + await Task.Delay(200); + + Assert.Equal("b", await Get(context)); // outlived its welcome; fetched again + Assert.Equal(2, executor.Sends); + Assert.True(cache.Expired > 0); + } + + [Fact] + public async Task AContextCanDemandSomethingFresherThanThePolicy() + { + // the one knob that is per-call, because freshness tolerance is a property of the caller - and the + // one that could not be added to IDatabase at all without a binary break + using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromHours(1) }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var relaxed = new RespContext().WithExecutor(executor).WithCache(cache); + var picky = relaxed.WithMaxCacheAge(TimeSpan.FromMilliseconds(50)); + + Assert.Equal("a", await Get(relaxed)); + await Task.Delay(150); + + // the relaxed caller is still happy with it... + Assert.Equal("a", await Get(relaxed)); + Assert.Equal(1, executor.Sends); + + // ...and the picky one is not + Assert.Equal("b", await Get(picky)); + Assert.Equal(2, executor.Sends); + } + + [Fact] + public async Task OneEntryServesCallersWithDifferentTolerances() + { + // age is applied on READ, not stamped on store - so a single entry serves everybody, rather than + // being duplicated once per distinct lifetime + using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromHours(1) }); + var executor = new CountingExecutor("$1\r\na\r\n"); + var relaxed = new RespContext().WithExecutor(executor).WithCache(cache); + var picky = relaxed.WithMaxCacheAge(TimeSpan.FromMinutes(30)); + + Assert.Equal("a", await Get(relaxed)); + Assert.Equal("a", await Get(picky)); + + Assert.Equal(1, executor.Sends); + Assert.Equal(1, cache.Count); // ONE entry, not one per tolerance + } + + [Fact] + public async Task AContextCannotAskForStalerThanThePolicyAllows() + { + // narrows, never widens: the deployment's lifetime is a ceiling + using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache) + .WithMaxCacheAge(TimeSpan.FromHours(1)); + + Assert.Equal("a", await Get(context)); + await Task.Delay(200); + + Assert.Equal("b", await Get(context)); + Assert.Equal(2, executor.Sends); + } +} From 8dc10d37d9ac03c796f0019da8c79ef7253c5345 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:14:06 +0100 Subject: [PATCH 097/360] Do not track agent worktrees .claude/worktrees/ is where agent worktrees are checked out - separate repositories, not content of this one. One got picked up as an embedded gitlink by a 'git add -A' in the previous commit; this removes it and ignores the directory so it cannot happen again. --- .claude/worktrees/strings-surface | 1 - .gitignore | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 160000 .claude/worktrees/strings-surface diff --git a/.claude/worktrees/strings-surface b/.claude/worktrees/strings-surface deleted file mode 160000 index 1f8e3cbd6..000000000 --- a/.claude/worktrees/strings-surface +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1f8e3cbd66e1156e7d276500f1398f4aabb1e7ae diff --git a/.gitignore b/.gitignore index e8b097e22..9395fd75e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ BenchmarkDotNet.Artifacts/ # local planning/design notes (not for publication) planning/ + +# agent worktrees (see .claude/); these are separate checkouts, never repo content +.claude/worktrees/ From 1fbc0a0512bb40e34e4ca5aff69b12c554a3e2c4 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:09 +0100 Subject: [PATCH 098/360] Reply handlers for the shapes the string groups need The vocabulary was RedisValue and bool; the rest of Strings needs long, double, RedisValue[], string, Lease and a handful of one-command shapes. Two decisions worth recording: `Ok` becomes `Boolean`, and is the only bool handler. SET replies +OK, SETBIT replies :0/:1, RESP3 has #t/#f, and a conditional write that did not happen replies nil - four wire shapes for one question. RespReader.ReadBoolean already accepts the first three (its two-byte simple-string case IS the IsOK compare), so the only thing left to decide is that nil means "no". A second, OK-only handler would buy one branch and cost every caller a decision. Shapes belonging to ONE command - LCSMatchResult, StringIncrementResult, the DIGEST condition - are registered in Inbuilt but not exposed as named properties. A handler nobody can reuse is not part of a vocabulary. Their parsing moves onto the types they produce (LCSMatchResult.TryRead, ValueCondition.TryReadDigest) and the existing ResultProcessors now call it, so the two readers cannot drift - the same arrangement Expiration already uses to share its operand switch between the two writers. --- .../APITypes/LCSMatchResult.Read.cs | 103 ++++++++ .../APITypes/LCSMatchResult.cs | 2 +- .../Interpolated/RespSurface.cs | 228 +++++++++++++++++- .../ResultProcessor.Digest.cs | 18 +- src/StackExchange.Redis/ResultProcessor.cs | 83 +------ src/StackExchange.Redis/ValueCondition.cs | 31 +++ 6 files changed, 367 insertions(+), 98 deletions(-) create mode 100644 src/StackExchange.Redis/APITypes/LCSMatchResult.Read.cs diff --git a/src/StackExchange.Redis/APITypes/LCSMatchResult.Read.cs b/src/StackExchange.Redis/APITypes/LCSMatchResult.Read.cs new file mode 100644 index 000000000..3f7ff3a2e --- /dev/null +++ b/src/StackExchange.Redis/APITypes/LCSMatchResult.Read.cs @@ -0,0 +1,103 @@ +using RESPite.Messages; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +public readonly partial struct LCSMatchResult +{ + /// + /// Read an LCS ... IDX reply: ["matches", [...], "len", n]. + /// + /// The reader, positioned on the top-level aggregate. + /// The parsed result, when this returns . + /// + /// + /// Shared by both readers - the ResultProcessor path and the interpolated surface's handler - + /// rather than restated in each, for the same reason shares its operand + /// switch between the two writers: this is the whole of the shape knowledge, and it is exactly the + /// part that would silently diverge if either kept its own copy. + /// + /// + /// The fields are read nominally, not positionally: the reply is documented as a map-shaped + /// array and the server is free to order it as it likes. + /// + /// + internal static bool TryRead(ref RespReader reader, out LCSMatchResult result) + { + result = default; + if (!reader.IsAggregate) return false; + + LCSMatch[]? matchesArray = null; + long longestMatchLength = 0; + + var iter = reader.AggregateChildren(); + while (iter.MoveNext() && iter.Value.IsScalar) + { + LCSField field; + unsafe + { + if (!iter.Value.TryParseScalar(&LCSFieldMetadata.TryParse, out field)) + { + field = LCSField.Unknown; + } + } + + if (!iter.MoveNext()) break; // out of data + + switch (field) + { + case LCSField.Matches: + if (iter.Value.IsAggregate) + { + bool failed = false; + matchesArray = iter.Value.ReadPastArray(ref failed, static (ref failed, ref reader) => + { + // Don't even bother if we've already failed + if (!failed && reader.IsAggregate) + { + var matchChildren = reader.AggregateChildren(); + if (matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var firstPos) + && matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var secondPos) + && matchChildren.MoveNext() && matchChildren.Value.IsScalar && matchChildren.Value.TryReadInt64(out var length)) + { + return new LCSMatch(firstPos, secondPos, length); + } + } + failed = true; + return default; + }); + + // Check if anything went wrong + if (failed) matchesArray = null; + } + break; + + case LCSField.Len: + if (iter.Value.IsScalar) + { + longestMatchLength = iter.Value.TryReadInt64(out var totalLen) ? totalLen : 0; + } + break; + } + } + + if (matchesArray is null) return false; + + result = new LCSMatchResult(matchesArray, longestMatchLength); + return true; + } + + private static bool TryReadPosition(ref RespReader reader, out LCSPosition position) + { + // Expecting a 2-element array: [start, end] + position = default; + if (!reader.IsAggregate) return false; + + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var start))) return false; + + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var end))) return false; + + position = new LCSPosition(start, end); + return true; + } +} diff --git a/src/StackExchange.Redis/APITypes/LCSMatchResult.cs b/src/StackExchange.Redis/APITypes/LCSMatchResult.cs index 3aca6357b..b6a24a39c 100644 --- a/src/StackExchange.Redis/APITypes/LCSMatchResult.cs +++ b/src/StackExchange.Redis/APITypes/LCSMatchResult.cs @@ -9,7 +9,7 @@ namespace StackExchange.Redis; /// Returns a list of the positions of each sub-match. /// // ReSharper disable once InconsistentNaming -public readonly struct LCSMatchResult +public readonly partial struct LCSMatchResult { internal static LCSMatchResult Null { get; } = new LCSMatchResult(Array.Empty(), 0); diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 4cc6adc09..0683daea8 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -35,8 +35,39 @@ public static class RespHandlers /// Reads a bulk string reply as a ; null stays null. public static IRespHandler Value { get; } = new ValueHandler(); - /// Reads a simple-string reply as success. - public static IRespHandler Ok { get; } = new OkHandler(); + /// Reads a reply as a boolean, in any of the spellings the server uses for one. + /// + /// One handler, not two. SET replies +OK, SETBIT replies :0/:1, + /// RESP3 has #t/#f, and a conditional write that did not happen replies nil - + /// four wire shapes for the same question. already accepts + /// the first three (its two-byte simple-string case IS the IsOK compare), so the only thing + /// left to decide here is that nil means "no", which is what it means everywhere it appears. + /// A separate OK-only handler would buy one branch and cost every caller a decision. + /// + public static IRespHandler Boolean { get; } = new BooleanHandler(); + + /// Reads an integer reply. + public static IRespHandler Int64 { get; } = new Int64Handler(); + + /// Reads an integer reply that may be nil, as BITFIELD's overflow case is. + public static IRespHandler NullableInt64 { get; } = new NullableInt64Handler(); + + /// Reads a floating-point reply; RESP2 sends these as bulk strings. + public static IRespHandler Double { get; } = new DoubleHandler(); + + /// Reads an array reply as s; a nil array reads as empty. + public static IRespHandler Values { get; } = new ValuesHandler(); + + /// Reads a bulk string reply as a ; null stays null. + public static IRespHandler String { get; } = new StringHandler(); + + /// Reads a bulk string reply as a ; null stays null. + /// + /// The lease always copies here, where the same read against a live reply may instead point + /// into the reply's buffer: a handler is handed a span whose lifetime ends when it returns, so + /// there is nothing to share. See . + /// + public static IRespHandler?> Lease { get; } = new LeaseHandler(); /// Checks the reply for a server error, and reads nothing else. /// @@ -66,7 +97,23 @@ internal static IRespHandler Require() { object? handler = null; if (typeof(T) == typeof(RedisValue)) handler = Value; - else if (typeof(T) == typeof(bool)) handler = Ok; + else if (typeof(T) == typeof(bool)) handler = Boolean; + else if (typeof(T) == typeof(long)) handler = Int64; + else if (typeof(T) == typeof(long?)) handler = NullableInt64; + else if (typeof(T) == typeof(double)) handler = Double; + else if (typeof(T) == typeof(RedisValue[])) handler = Values; + else if (typeof(T) == typeof(string)) handler = String; + else if (typeof(T) == typeof(Lease)) handler = Lease; + + // Below this line: shapes that belong to ONE command. They are registered so a command + // body stays one expression, but they are not exposed as named properties - a handler + // nobody can reuse is not part of a vocabulary, and RespHandlers is the vocabulary. If a + // shape ever earns a second caller, promoting it is a one-line change. + else if (typeof(T) == typeof(ValueCondition?)) handler = s_digest; + else if (typeof(T) == typeof(LCSMatchResult)) handler = s_lcsMatch; + else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementInt64; + else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementDouble; + else if (typeof(T) == typeof(Lease)) handler = s_nullableInt64Lease; return (IRespHandler?)handler; } } @@ -91,13 +138,184 @@ public bool Parse(ReadOnlySpan response) } } - private sealed class OkHandler : IRespHandler + private sealed class BooleanHandler : IRespHandler { public bool Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); - return reader.IsOK(); // one 16-bit compare, inlined - and accepts '+ok' as well as '+OK' + + // nil is not a failure here, and this is the one place that has to say so: a SET under + // NX/XX that did not write, a GETEX on a missing key - the command worked, the answer is no + return !reader.IsNull && reader.ReadBoolean(); + } + } + + private sealed class Int64Handler : IRespHandler + { + public long Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadInt64(); + } + } + + private sealed class NullableInt64Handler : IRespHandler + { + public long? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + + // a single-operation BITFIELD still replies with an array; unwrap a unit one, as the + // MessageWriter path's NullableInt64Processor does, so the caller sees one value + if (reader.IsAggregate) reader.MoveNext(); + + return reader.IsNull ? null : reader.ReadInt64(); + } + } + + private sealed class DoubleHandler : IRespHandler + { + public double Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadDouble(); + } + } + + private sealed class ValuesHandler : IRespHandler + { + public RedisValue[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + + // a nil array - which MGET does not send, but a RESP3 server may for an empty aggregate - + // reads as empty rather than null, because every caller of an array reply wants to iterate it + return reader.ReadPastRedisValues() ?? Array.Empty(); + } + } + + private sealed class StringHandler : IRespHandler + { + public string? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.IsNull ? null : reader.ReadString(); + } + } + + private sealed class LeaseHandler : IRespHandler?> + { + public Lease? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadLease(); + } + } + + // ---- one-command shapes; reachable through Inbuilt, deliberately not named above ---- + private static readonly IRespHandler s_digest = new DigestHandler(); + private static readonly IRespHandler s_lcsMatch = new LCSMatchHandler(); + private static readonly IRespHandler> s_incrementInt64 = new IncrementInt64Handler(); + private static readonly IRespHandler> s_incrementDouble = new IncrementDoubleHandler(); + private static readonly IRespHandler> s_nullableInt64Lease = new NullableInt64LeaseHandler(); + + private sealed class DigestHandler : IRespHandler + { + public ValueCondition? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return ValueCondition.TryReadDigest(in reader, out var digest) + ? digest + : throw new RespException("Unexpected DIGEST reply."); + } + } + + private sealed class LCSMatchHandler : IRespHandler + { + public LCSMatchResult Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return LCSMatchResult.TryRead(ref reader, out var result) + ? result + : throw new RespException("Unexpected LCS IDX reply."); + } + } + + private sealed class IncrementInt64Handler : IRespHandler> + { + public StringIncrementResult Parse(ReadOnlySpan response) + { + // [value, applied-increment]; under a bound the second is not the one that was asked for + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsAggregate + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var value) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var applied)) + { + return new StringIncrementResult(value, applied); + } + + throw new RespException("Unexpected INCREX reply."); + } + } + + private sealed class NullableInt64LeaseHandler : IRespHandler> + { + public Lease Parse(ReadOnlySpan response) + { + // BITFIELD's reply: a flat array with one element per sub-operation, nil where + // OVERFLOW FAIL skipped one + var reader = new RespReader(response); + reader.MoveNext(); + reader.DemandAggregate(); + if (reader.IsNull) return Lease.Empty; + + var length = reader.AggregateLength(); + if (length == 0) return Lease.Empty; + + var lease = Lease.Create(length, clear: false); + try + { + var target = lease.Span; + for (var i = 0; i < length; i++) + { + reader.MoveNextScalar(); + target[i] = reader.IsNull ? null : reader.ReadInt64(); + } + } + catch + { + lease.Dispose(); + throw; + } + + return lease; + } + } + + private sealed class IncrementDoubleHandler : IRespHandler> + { + public StringIncrementResult Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsAggregate + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadDouble(out var value) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadDouble(out var applied)) + { + return new StringIncrementResult(value, applied); + } + + throw new RespException("Unexpected INCREX reply."); } } } diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs index a0093fbc8..f7dbf3e5e 100644 --- a/src/StackExchange.Redis/ResultProcessor.Digest.cs +++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs @@ -12,20 +12,12 @@ private sealed class DigestProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (reader.IsNull) // for example, key doesn't exist - { - SetResult(message, null); - return true; - } + // the shape lives on the type it produces, so the interpolated surface's handler reads the + // identical reply the identical way; see ValueCondition.TryReadDigest + if (!ValueCondition.TryReadDigest(in reader, out var digest)) return false; - if (reader.ScalarLengthIs(2 * ValueCondition.DigestBytes)) - { - var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[2 * ValueCondition.DigestBytes]); - var digest = ValueCondition.ParseDigest(span); - SetResult(message, digest); - return true; - } - return false; + SetResult(message, digest); + return true; } } } diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 5da9dbb64..19ab4f745 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -2076,86 +2076,11 @@ private sealed class LongestCommonSubsequenceProcessor : ResultProcessor - { - // Don't even bother if we've already failed - if (!failed && reader.IsAggregate) - { - var matchChildren = reader.AggregateChildren(); - if (matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var firstPos) - && matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var secondPos) - && matchChildren.MoveNext() && matchChildren.Value.IsScalar && matchChildren.Value.TryReadInt64(out var length)) - { - return new LCSMatchResult.LCSMatch(firstPos, secondPos, length); - } - } - failed = true; - return default; - }); - - // Check if anything went wrong - if (failed) matchesArray = null; - } - break; - - case LCSField.Len: - // Read the length value - if (iter.Value.IsScalar) - { - longestMatchLength = iter.Value.TryReadInt64(out var totalLen) ? totalLen : 0; - } - break; - } - } - - if (matchesArray is not null) - { - SetResult(message, new LCSMatchResult(matchesArray, longestMatchLength)); - return true; - } - } - return false; - } - - private static bool TryReadPosition(ref RespReader reader, out LCSMatchResult.LCSPosition position) - { - // Expecting a 2-element array: [start, end] - position = default; - if (!reader.IsAggregate) return false; - - if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var start))) return false; - - if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var end))) return false; + // the shape lives on the type it produces, so the interpolated surface's handler reads + // the identical reply the identical way; see LCSMatchResult.Read.cs + if (!StackExchange.Redis.LCSMatchResult.TryRead(ref reader, out var result)) return false; - position = new LCSMatchResult.LCSPosition(start, end); + SetResult(message, result); return true; } } diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 471316e0f..d93c13ba2 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -6,6 +6,7 @@ using System.IO.Hashing; using System.Runtime.CompilerServices; using RESPite; +using RESPite.Messages; namespace StackExchange.Redis; @@ -421,6 +422,36 @@ internal static Span WriteHex(long value, Span target) internal ValueCondition ThrowInvalidOperation([CallerMemberName] string? operation = null) => throw new InvalidOperationException($"{operation} cannot be used with a {_kind} condition."); + /// + /// Read a DIGEST reply as the condition a later write can be gated on; null for a key that + /// does not exist. + /// + /// The reader, positioned on the reply. + /// The parsed digest, when this returns . + /// + /// Shared by both readers - the ResultProcessor path and the interpolated surface's handler - + /// for the same reason is shared by both writers: one copy of the shape + /// knowledge, so there is nothing to fall out of step. + /// + internal static bool TryReadDigest(in RespReader reader, out ValueCondition? digest) + { + if (reader.IsNull) // for example, key doesn't exist + { + digest = null; + return true; + } + + if (reader.ScalarLengthIs(2 * DigestBytes)) + { + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[2 * DigestBytes]); + digest = ParseDigest(span); + return true; + } + + digest = null; + return false; + } + internal When AsWhen() => _kind switch { ConditionKind.Always => When.Always, From 1b00980b046cebcc0e62ad126834995df32c5e93 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:15 +0100 Subject: [PATCH 099/360] Variadic holes, and frames that know which command they are Two additions to the writer, both of which the string groups turned out to need before a single command could be written as one expression. **Runs of keys and pairs are holes.** `$"{RedisCommand.MGET}{keys}"` and `$"{RedisCommand.MSET}{values}"` render a run, each key prefixed, marked for invalidation and folded into the slot exactly as a single key is. Without it a variadic command is Compose plus a loop plus a try/finally to hand the rented buffer back if an interpolation throws - which is real code, per command, that says nothing. The pairs overload exists separately because the key half of a pair goes through the key path and the value half does not; writing a key as a value would lose the prefix, the mark and the cross-slot check, silently. **The frame carries the command's identity as well as its bytes.** RespFrame and RespRequest now record which RedisCommand was written. This is not decoration: RespMessageExecutor wrapped every frame as RedisCommand.UNKNOWN, so the pipeline could not tell a write from a read - IsPrimaryOnly let a write be routed to a replica whenever a caller demanded one, and a profiler reported the whole new surface as UNKNOWN. Identity is deliberately absent from equality, so two frames with the same bytes remain one cache entry. RespLiterals gains the keyword arguments the groups need, generated rather than hand-written so framing, length prefixes and argument counts are right by construction. Nothing whose keyword belongs to a value type is listed there: EX/PXAT/KEEPTTL stay on Expiration and NX/IFEQ stay on ValueCondition, which is what lets the whole of SET render with no branch. --- .../Interpolated/RespCommandHandler.cs | 55 ++++++++- .../Interpolated/RespFrame.cs | 27 ++++- .../Interpolated/RespFrameWriter.cs | 10 +- .../Interpolated/RespLiterals.cs | 107 ++++++++++++++++++ .../Interpolated/RespRequest.cs | 9 +- 5 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespLiterals.cs diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 8d7e140f9..9ef809093 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; @@ -50,6 +51,8 @@ public ref struct RespCommandHandler private int _keyOffsetA; // buffer-absolute offsets of the first two keys private int _keyOffsetB; private bool _hasCommand; + private RedisCommand _command; // the command's IDENTITY, for routing and diagnostics; the + // bytes are already written, so this is never used to render /// Initialize with the command supplied as the first hole. /// Total length of the literal segments; compiler-supplied. @@ -64,6 +67,7 @@ public RespCommandHandler(int literalLength, int formattedCount, RespContext con _argIndex = 0; _slot = ServerSelectionStrategy.NoSlot; _hasCommand = false; + _command = RedisCommand.UNKNOWN; // set by the first AppendFormatted, which is the command hole } /// @@ -89,6 +93,7 @@ internal RespCommandHandler(int literalLength, int formattedCount, RespContext c resp.CopyTo(_buffer.AsSpan(_offset)); _offset += resp.Length; _hasCommand = true; + _command = command; _args = 1; _argIndex = 1; } @@ -244,6 +249,7 @@ internal void AppendFormatted(RedisCommand value) { if (_hasCommand) throw new InvalidOperationException("The command must be the first argument, and may only be given once."); + _command = value; // identity, for routing and diagnostics; see the field var resp = _context.ResolveCommand(value); Ensure(resp.Length); @@ -293,6 +299,7 @@ public void AppendFormatted(RespCommand value) _offset += resp.Length; } + if (!_hasCommand) _command = value.Command; // only the FIRST one is the command _hasCommand = true; // whether it was the command or merely the first thing written _args++; _argIndex++; @@ -482,6 +489,52 @@ public void AppendFormatted(RespFragment value) _argIndex += value.ArgCount; } + /// + /// Append a run of keys, each one prefixed, marked and folded into the slot exactly as a single + /// key is: $"{RedisCommand.MGET}{keys}". + /// + /// The keys to append; an empty run appends nothing. + /// + /// + /// A variadic command is the one shape the single-expression form could not otherwise reach: the + /// argument count is a run-time quantity, so the alternative is Compose plus a loop plus a + /// try/finally to hand the rented buffer back if an interpolation throws. This makes + /// MGET, DEL and BITOP read like every other command. + /// + /// + /// A span rather than an array, so a caller with a slice, a stackalloc, or an array it does + /// not want copied pays nothing; an array converts implicitly, so $"{keys}" compiles either + /// way. scoped for the usual reason: nothing here retains it. + /// + /// + public void AppendFormatted(scoped ReadOnlySpan value) + { + foreach (ref readonly var key in value) + { + AppendFormatted(key); + } + } + + /// + /// Append a run of key/value pairs, in the order MSET wants them: + /// $"{RedisCommand.MSET}{values}". + /// + /// The pairs to append; an empty run appends nothing. + /// + /// Each pair contributes TWO arguments, and the key half goes through the key path - prefix, mark, + /// slot - while the value half does not. That asymmetry is the entire reason this is a hole rather + /// than something the caller loops over as values: writing a key as a value would lose the prefix, + /// the invalidation mark and the cross-slot check, all silently. + /// + public void AppendFormatted(scoped ReadOnlySpan> value) + { + foreach (ref readonly var pair in value) + { + AppendFormatted(pair.Key); + AppendFormatted(pair.Value); + } + } + /// Append a value; not a key, and not marked as one. /// The value to append. public void AppendFormatted(RedisValue value) @@ -511,7 +564,7 @@ public RespFrame Complete() var start = HeaderMax - headerLength; header.Slice(0, headerLength).CopyTo(_buffer.AsSpan(start)); - var frame = new RespFrame(_buffer, start, _offset - start, _args, _slot, PackKeyMarks()); + var frame = new RespFrame(_buffer, start, _offset - start, _args, _slot, PackKeyMarks(), _command); _buffer = null!; // ownership transferred to the frame return frame; } diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 7172824aa..57d915a8b 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -34,7 +34,7 @@ public struct RespFrame : IDisposable private readonly int _length; private readonly ulong _keyMarks; - internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, ulong keyMarks) + internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, ulong keyMarks, RedisCommand command) { _buffer = buffer; _start = start; @@ -42,11 +42,30 @@ internal RespFrame(byte[] buffer, int start, int length, int argCount, int slot, _keyMarks = keyMarks; ArgCount = argCount; Slot = slot; + Command = command; } /// The number of RESP arguments, including the command itself. public int ArgCount { get; } + /// + /// Which command this is, for everything downstream that has to know WHAT is being sent rather than + /// what the bytes are: whether a replica may serve it, and what a profiler or an error should say. + /// + /// + /// + /// The bytes already carry the command - mapped, framed, and settled - so this is never used to + /// render anything. It is identity, not content, which is why it is deliberately absent from + /// equality: two frames with the same bytes are the same cache entry whatever route they took. + /// + /// + /// for a command given only as a name that the map did not + /// recognise - the same thing IDatabase.Execute(string, ...) reports, and for the same + /// reason: there is nothing to know. + /// + /// + internal RedisCommand Command { get; } + /// The combined cluster slot, or /. public int Slot { get; } @@ -230,7 +249,8 @@ public RespRequest Detach(CommandFlags flags = CommandFlags.None) _keyMarks, Slot, ArgCount, - flags); + flags, + Command); } /// @@ -261,7 +281,8 @@ public RespRequest AsLookupKey(CommandFlags flags = CommandFlags.None) _keyMarks, Slot, ArgCount, - flags); + flags, + Command); } /// Return the underlying buffer to the pool; safe to call more than once. diff --git a/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs b/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs index 61ff67ae0..483adf1a5 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrameWriter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Diagnostics.CodeAnalysis; using RESPite; @@ -83,11 +83,17 @@ public Span GetSpan(int sizeHint = 0) /// The cluster slot, which Message.GetHashSlot already computes - so unlike the interpolated /// writer there is no need to fold it during the write. /// + /// + /// The frame's Command is left UNKNOWN: this writer is fed by MessageWriter, which + /// already has a Message carrying the command, so nothing downstream of here would learn + /// anything from a copy of it. The interpolated writer is the one that has to record it, because + /// there the frame IS the whole message. + /// public RespFrame Complete(int slot = ServerSelectionStrategy.NoSlot) { var buffer = _buffer; var length = _offset; - var frame = new RespFrame(buffer, 0, length, ReadArgCount(buffer, length), slot, PackKeyMarks(buffer, length)); + var frame = new RespFrame(buffer, 0, length, ReadArgCount(buffer, length), slot, PackKeyMarks(buffer, length), RedisCommand.UNKNOWN); _buffer = ArrayPool.Shared.Rent(Math.Max(16, length)); Reset(); diff --git a/src/StackExchange.Redis/Interpolated/RespLiterals.cs b/src/StackExchange.Redis/Interpolated/RespLiterals.cs new file mode 100644 index 000000000..1d423de57 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespLiterals.cs @@ -0,0 +1,107 @@ +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The keyword arguments the command surface writes, pre-framed. + /// + /// + /// + /// The author writes the declaration; RespFragmentGenerator emits the bytes, the length + /// prefixes and the argument count, so all three are correct by construction rather than by review. + /// Hand-written fragments are gated behind SER011 precisely because a mistake in any of them desyncs + /// the connection, with the first symptom appearing somewhere unrelated. + /// + /// + /// Distinct from , which holds the same tokens as + /// s for the MessageWriter path: those are framed at write time, these + /// are framed at compile time. Neither is a wrapper for the other, and the pair is temporary - the + /// older one retires with the writer it serves. + /// + /// + /// Only tokens that cannot come from somewhere better live here. A keyword that belongs to a value - + /// EX/PXAT/KEEPTTL to , NX/IFEQ to + /// - stays owned by that type, which is what lets + /// $"{cmd}{key}{value}{when}{expiry}" render the whole of SET without a branch. + /// + /// + internal static partial class RespLiterals + { + /// The GET operand of SET, which makes it reply with the previous value. + [Resp] + internal static partial RespFragment Get { get; } + + /// The LEN operand of LCS. + [Resp] + internal static partial RespFragment Len { get; } + + /// The IDX operand of LCS. + [Resp] + internal static partial RespFragment Idx { get; } + + /// The MINMATCHLEN operand of LCS; a length follows it. + [Resp] + internal static partial RespFragment MinMatchLen { get; } + + /// The WITHMATCHLEN operand of LCS. + [Resp] + internal static partial RespFragment WithMatchLen { get; } + + /// + /// The BIT index type of BITCOUNT/BITPOS. There is deliberately no + /// BYTE counterpart: it is the server's own default, so it is rendered as nothing at all. + /// + [Resp] + internal static partial RespFragment Bit { get; } + + /// The BYINT increment kind of INCREX; the amount follows it. + [Resp] + internal static partial RespFragment ByInt { get; } + + /// The BYFLOAT increment kind of INCREX; the amount follows it. + [Resp] + internal static partial RespFragment ByFloat { get; } + + /// The LBOUND operand of INCREX; the bound follows it. + [Resp] + internal static partial RespFragment LBound { get; } + + /// The UBOUND operand of INCREX; the bound follows it. + [Resp] + internal static partial RespFragment UBound { get; } + + /// The SATURATE operand of INCREX. + [Resp] + internal static partial RespFragment Saturate { get; } + + /// The AND operation of BITOP. + [Resp] + internal static partial RespFragment And { get; } + + /// The OR operation of BITOP. + [Resp] + internal static partial RespFragment Or { get; } + + /// The XOR operation of BITOP. + [Resp] + internal static partial RespFragment Xor { get; } + + /// The NOT operation of BITOP. + [Resp] + internal static partial RespFragment Not { get; } + + /// The DIFF operation of BITOP. + [Resp] + internal static partial RespFragment Diff { get; } + + /// The DIFF1 operation of BITOP. + [Resp] + internal static partial RespFragment Diff1 { get; } + + /// The ANDOR operation of BITOP. + [Resp] + internal static partial RespFragment AndOr { get; } + + /// The ONE operation of BITOP. + [Resp] + internal static partial RespFragment One { get; } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespRequest.cs b/src/StackExchange.Redis/Interpolated/RespRequest.cs index 5290deea2..262a13a73 100644 --- a/src/StackExchange.Redis/Interpolated/RespRequest.cs +++ b/src/StackExchange.Redis/Interpolated/RespRequest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Diagnostics.CodeAnalysis; using RESPite; @@ -55,7 +55,8 @@ internal RespRequest( ulong keyMarks = 0, int slot = ServerSelectionStrategy.NoSlot, int argCount = 0, - CommandFlags flags = CommandFlags.None) + CommandFlags flags = CommandFlags.None, + RedisCommand command = RedisCommand.UNKNOWN) { _array = array; _lease = lease; @@ -66,6 +67,7 @@ internal RespRequest( Slot = slot; ArgCount = argCount; Flags = flags; + Command = command; } /// The combined cluster slot; routing needs this and nothing else about the keys. @@ -74,6 +76,9 @@ internal RespRequest( /// The number of RESP arguments, including the command itself. public int ArgCount { get; } + /// + internal RedisCommand Command { get; } + /// /// The command's flags: the retry category a retrying executor needs, and the caching gates. /// From 921aa4f8b7cef4812b98747a6b69412dd9cccd07 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:20 +0100 Subject: [PATCH 100/360] The rest of the string commands Everything single-frame that IDatabase exposes as String*: APPEND, STRLEN, GETRANGE, SETRANGE, GETDEL, GETEX, MGET, MSET, INCRBY/INCRBYFLOAT, the bounded INCREX form, SET..GET, DEL/DELEX, DIGEST, LCS in its three shapes, and the lease spelling of GET. StringGetWithExpiry is the one left behind: it is a pipelined TTL+GET composite, and a composite is not a frame. Where the old surface has several methods for one request, there is one here: - GETEX is one method taking an Expiration, where the old surface has a TimeSpan? overload, a DateTime overload, and a separate StringPersist. All three are spellings Expiration already has. - BITOP-style multi-key forms drop the fixed-arity twin entirely, now that a run of keys is a hole. - There is no Decrement. DECRBY n and INCRBY -n are the same request with the same reply, and RedisDatabase already implements one as the other - so keeping it would buy a method whose only content is a minus sign. INCR/DECR go the way SETEX went, for the same reason. The retry category now comes from CommandFlagsExtensions.WithDefaultCategory - the same per-command table the MessageWriter path uses - rather than a constant named at each call site. Two writers agreeing on the bytes and disagreeing on whether a command is safe to replay is a divergence nothing would catch. Commands whose ARGUMENTS change the answer say so explicitly: a bare GETEX is the read the table says it is, but any of EX/PX/EXAT/PXAT/PERSIST mutates the TTL and makes it a write. A null value still deletes the key, as it always has on this surface: there is no SET that stores "no value", and writing an empty string instead would be a different value, silently. --- .../Interpolated/RespSurface.Strings.cs | 504 +++++++++++++++++- .../TransitionalDatabase.Strings.cs | 226 +++++++- 2 files changed, 723 insertions(+), 7 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 786ffb477..6bfc40a4d 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using RESPite; @@ -60,13 +62,154 @@ public static partial class RespSurface // `in` because RespStrings is a readonly struct: no defensive copy, and nothing to copy on the // way to a network round trip. + // The retry category comes from CommandFlagsExtensions.WithDefaultCategory - the same per-command + // table the MessageWriter path uses - rather than a constant named at each call site. Two writers + // agreeing on the bytes and disagreeing on whether a command is safe to replay is the kind of + // divergence nothing would catch; the table is the single source of truth, and a command whose + // ARGUMENTS change the answer (GETEX with a TTL, SET under NX) raises it explicitly and says why. + // WithRetryCategory stays public for surfaces outside this assembly, which cannot see the table. + /// GET. /// The string command group. /// The key to read. /// Command flags. +#pragma warning disable RS0026 // the key/keys overloads are disambiguated by the first parameter public static ValueTask Get(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 => strings.Context.SendAsync( - $"{RedisCommand.GET}{key}", flags.WithRetryCategory(CommandFlags.CommandRetryReadOnly)); + $"{RedisCommand.GET}{key}", flags.WithDefaultCategory(RedisCommand.GET)); + + /// MGET. + /// The string command group. + /// The keys to read. + /// Command flags. + /// + /// + /// The variadic form, in one expression: {keys} is a hole like any other, and each key in it + /// is prefixed, marked for invalidation and folded into the cluster slot exactly as a single key is. + /// Without that hole this would be Compose, a loop and a try/finally. + /// + /// + /// No keys means no command: an arity-zero MGET is a server error, and "the values of no + /// keys" is an empty array without asking anyone. The send is skipped, so this completes + /// synchronously and allocates nothing. + /// + /// +#pragma warning disable RS0026 // the key/keys overloads are disambiguated by the first parameter + public static ValueTask Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => keys.IsEmpty + ? new ValueTask(Array.Empty()) + : strings.Context.SendAsync( + $"{RedisCommand.MGET}{keys}", flags.WithDefaultCategory(RedisCommand.MGET)); + + /// GET, retaining the payload as a rather than a value. + /// The string command group. + /// The key to read. + /// Command flags. + /// + /// Same command, different result shape - which is why it is a separate method rather than an + /// overload: the two differ only in return type, and C# does not overload on that. The lease must + /// be disposed. + /// + public static ValueTask?> GetLease(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync?>( + $"{RedisCommand.GET}{key}", flags.WithDefaultCategory(RedisCommand.GET)); + + /// GETRANGE. + /// The string command group. + /// The key to read. + /// The inclusive start offset; negative counts back from the end. + /// The inclusive end offset; negative counts back from the end. + /// Command flags. + public static ValueTask GetRange(this in RespStrings strings, RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.GETRANGE}{key}{start}{end}", flags.WithDefaultCategory(RedisCommand.GETRANGE)); + + /// GETDEL. + /// The string command group. + /// The key to read and remove. + /// Command flags. + public static ValueTask GetDelete(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.GETDEL}{key}", flags.WithDefaultCategory(RedisCommand.GETDEL)); + + /// GETEX: read the value, and set, keep or clear the expiration in the same call. + /// The string command group. + /// The key to read. + /// + /// The expiration to apply; leaves the TTL untouched, and + /// clears it. + /// + /// Command flags. + /// + /// + /// One method where the old surface has three. StringGetSetExpiry exists as a + /// TimeSpan? overload and a DateTime one, and neither can say PERSIST - + /// StringPersist is a separate command. already spells all of + /// those, so taking it collapses the set without losing a single case. + /// + /// + /// The retry category depends on the argument: a bare GETEX is the pure read the table says + /// it is, but any of EX/PX/EXAT/PXAT/PERSIST mutates the TTL and makes it a write. ENX has no + /// spelling here at all, and says so rather than letting it + /// render into a command the server will reject. + /// + /// + public static ValueTask GetSetExpiry(this in RespStrings strings, RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) + { + var mutatesTtl = expiry.GetTokenCount(allowEnx: false) != 0; + if (mutatesTtl) flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); + + return strings.Context.SendAsync( + $"{RedisCommand.GETEX}{key}{expiry}", flags.WithDefaultCategory(RedisCommand.GETEX)); + } + + /// STRLEN. + /// The string command group. + /// The key to measure. + /// Command flags. + public static ValueTask Length(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.STRLEN}{key}", flags.WithDefaultCategory(RedisCommand.STRLEN)); + + /// APPEND; the reply is the new length. + /// The string command group. + /// The key to append to. + /// The value to append. + /// Command flags. + public static ValueTask Append(this in RespStrings strings, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.APPEND}{key}{value}", flags.WithDefaultCategory(RedisCommand.APPEND)); + + /// SETRANGE; the reply is the new length. + /// The string command group. + /// The key to write into. + /// The byte offset to write at; the value is zero-padded up to it. + /// The value to write. + /// Command flags. + /// + /// long, not RedisValue. SETRANGE replies with an integer and always has; the + /// old surface returns RedisValue, which makes every caller ask a second question of a reply + /// that only ever answers one way. The adapter converts, so nothing observable changes for the old + /// spelling. + /// + public static ValueTask SetRange(this in RespStrings strings, RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.SETRANGE}{key}{offset}{value}", flags.WithDefaultCategory(RedisCommand.SETRANGE)); + + /// DIGEST: the server's hash of the value, as a condition a later write can be gated on. + /// The string command group. + /// The key to digest. + /// Command flags. + /// + /// when the key does not exist. The result is directly usable as the + /// when of a later , + /// which is the whole point of returning a rather than bytes. + /// + public static ValueTask Digest(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.DIGEST}{key}", flags.WithDefaultCategory(RedisCommand.DIGEST)); /// SET, in full: expiration and value condition included. /// The string command group. @@ -117,7 +260,15 @@ public static ValueTask Get(this in RespStrings strings, RedisKey ke /// for "no opinion", and WithRetryCategory is first-wins, so /// a caller who names a category still keeps it. /// + /// + /// A null value deletes the key, as it always has on this library's surface. There is no + /// SET that stores "no value" - the nearest thing the protocol offers is an empty string, + /// which is a different value, and writing that instead would turn "remove this" into + /// "store nothing here" without saying so. The condition and expiration have no meaning for a + /// delete and are dropped, which is also what the old builder does. + /// /// +#pragma warning disable RS0026 // the single-key and multi-key overloads are disambiguated by the second parameter public static ValueTask Set( this in RespStrings strings, RedisKey key, @@ -125,9 +276,350 @@ public static ValueTask Set( Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) - => strings.Context.SendAsync( - $"{RedisCommand.SET}{key}{value}{when}{expiry}", - flags.WithRetryCategory(when.RetryCategory) - .WithRetryCategory(CommandFlags.CommandRetryWriteLastWins)); + => value.IsNull + ? Delete(in strings, key, when: default, flags) + : strings.Context.SendAsync( + $"{RedisCommand.SET}{key}{value}{when}{expiry}", + flags.WithRetryCategory(when.RetryCategory) + .WithDefaultCategory(RedisCommand.SET)); +#pragma warning restore RS0026 + + /// MSET/MSETNX/MSETEX: set several keys in one command. + /// The string command group. + /// The key/value pairs to write. + /// When the keys should expire; default for no expiration. + /// The condition the write is subject to; default to write unconditionally. + /// Command flags. + /// + /// + /// Three commands behind one method, and unlike SET's arity relics these are not + /// interchangeable. MSETEX alone can carry an expiration or a condition, but it is a + /// recent addition; MSET and MSETNX have been there since 1.0.1. So the choice is + /// made on what the caller actually asked for, and the widely-available command is used whenever + /// it can express the request - which is a server-version decision, not an arity one, and is why + /// this branch survives where SET's did not. + /// + /// + /// A value condition beyond NX/XX has no multi-key spelling at all, and says so here rather than + /// rendering a command the server will reject. + /// + /// + /// No pairs means no command, as with : + /// writing nothing succeeded. + /// + /// +#pragma warning disable RS0026 // the single-key and multi-key overloads are disambiguated by the second parameter + public static ValueTask Set( + this in RespStrings strings, + ReadOnlySpan> values, + Expiration expiry = default, + ValueCondition when = default, + CommandFlags flags = CommandFlags.None) + { + if (values.IsEmpty) return new ValueTask(true); + + var command = when.Kind switch + { + ValueCondition.ConditionKind.Always when expiry.IsNone => RedisCommand.MSET, + + // "keep the TTL" and "the key must not exist" cannot disagree: there is no TTL to keep + ValueCondition.ConditionKind.NotExists when expiry.IsNoneOrKeepTtl => RedisCommand.MSETNX, + + ValueCondition.ConditionKind.Always + or ValueCondition.ConditionKind.Exists + or ValueCondition.ConditionKind.NotExists => RedisCommand.MSETEX, + + _ => ThrowUnsupportedCondition(when, nameof(Set)), + }; + + flags = flags.WithRetryCategory(when.RetryCategory).WithDefaultCategory(command); + + // MSET/MSETNX take the pairs and nothing else; MSETEX prefixes a count and accepts the tail + return command == RedisCommand.MSETEX + ? strings.Context.SendAsync($"{command}{values.Length}{values}{expiry}{when}", flags) + : strings.Context.SendAsync($"{command}{values}", flags); + } +#pragma warning restore RS0026 + + /// SET ... GET: write the value, and reply with the one it replaced. + /// The string command group. + /// The key to write. + /// The value to write. + /// When the key should expire; default for no expiration. + /// The condition the write is subject to; default to write unconditionally. + /// Command flags. + /// + /// + /// The canonical form, and GETSET is not emitted at all - it has been deprecated in favour + /// of SET ... GET since 6.2, and unlike GETSET this one composes with NX/XX and with + /// an expiration. + /// + /// + /// Operand order is the documented grammar, as in + /// : + /// the condition, then GET, then the expiration. The old builder emits EX n XX GET, + /// which Redis parses and another RESP server need not. + /// + /// + /// A nil reply is ambiguous by nature - the key was absent, or the condition refused the write - + /// and that ambiguity is the command's, not ours. A caller who needs to tell them apart wants + /// , + /// whose boolean answers exactly that question. + /// + /// + public static ValueTask SetAndGet( + this in RespStrings strings, + RedisKey key, + RedisValue value, + Expiration expiry = default, + ValueCondition when = default, + CommandFlags flags = CommandFlags.None) + => value.IsNull + ? GetDelete(in strings, key, flags) // as Set: a null value removes the key, and GETDEL is the read-it-back form + : strings.Context.SendAsync( + $"{RedisCommand.SET}{key}{value}{when}{RespLiterals.Get}{expiry}", + flags.WithRetryCategory(when.RetryCategory) + .WithDefaultCategory(RedisCommand.SET)); + + /// DEL/DELEX: remove a key, optionally only if it still holds what you think it does. + /// The string command group. + /// The key to remove. + /// The condition the delete is subject to; default to delete unconditionally. + /// Command flags. + /// + /// + /// is the same request as no condition - DEL already + /// means "if it is there" - so both render DEL. A value or digest test needs DELEX, + /// which is the whole reason this takes a condition at all. + /// + /// + /// has no meaning here and is rejected: "delete it if it is + /// absent" is not a request the server can be asked, and quietly treating it as + /// would delete the key the caller was protecting. + /// + /// + public static ValueTask Delete( + this in RespStrings strings, + RedisKey key, + ValueCondition when = default, + CommandFlags flags = CommandFlags.None) + { + switch (when.Kind) + { + case ValueCondition.ConditionKind.Always: + case ValueCondition.ConditionKind.Exists: + return strings.Context.SendAsync( + $"{RedisCommand.DEL}{key}", flags.WithDefaultCategory(RedisCommand.DEL)); + + case ValueCondition.ConditionKind.ValueEquals: + case ValueCondition.ConditionKind.ValueNotEquals: + case ValueCondition.ConditionKind.DigestEquals: + case ValueCondition.ConditionKind.DigestNotEquals: + return strings.Context.SendAsync( + $"{RedisCommand.DELEX}{key}{when}", + flags.WithRetryCategory(when.RetryCategory).WithDefaultCategory(RedisCommand.DELEX)); + + default: + return ThrowUnsupportedCondition>(when, nameof(Delete)); + } + } + + /// INCRBY, and INCRBYFLOAT for the floating-point twin. + /// The string command group. + /// The key to increment. + /// The amount to add. + /// Command flags. + /// + /// + /// There is deliberately no Decrement. DECRBY key n and INCRBY key -n are the + /// same request with the same reply, and the old surface already implements one as the other - + /// StringDecrement is a negation and a call to StringIncrement. Keeping the second + /// spelling here would buy a method whose only content is a minus sign. + /// + /// + /// INCR and DECR go the same way, for the reason SETEX did: they are + /// INCRBY key 1 with the argument removed, which saves four bytes on the wire and costs a + /// branch on every call. + /// + /// +#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry + public static ValueTask Increment(this in RespStrings strings, RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => strings.Context.SendAsync( + $"{RedisCommand.INCRBY}{key}{value}", flags.WithDefaultCategory(RedisCommand.INCRBY)); + + /// + /// The string command group. + /// The key to increment. + /// The amount to add. + /// Command flags. +#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry + public static ValueTask Increment(this in RespStrings strings, RedisKey key, double value, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => strings.Context.SendAsync( + $"{RedisCommand.INCRBYFLOAT}{key}{value}", flags.WithDefaultCategory(RedisCommand.INCRBYFLOAT)); + + /// INCREX: increment with an expiration, and optionally with bounds. + /// The string command group. + /// The key to increment. + /// The amount to add. + /// When the key should expire; ENX applies it only to a new key. + /// The lowest value the result may take, if any. + /// The highest value the result may take, if any. + /// Whether a bound clamps the result or rejects the increment. + /// Command flags. + /// + /// + /// A separate command rather than an optional argument on + /// : the reply shape differs - + /// INCREX answers with the new value and the increment that was actually applied, + /// which under a bound is not the one you asked for. + /// + /// + /// KEEPTTL and PERSIST have no spelling here, and a bare ENX without an + /// expiration is not a request; all three are rejected at the call site rather than rendered into + /// a command the server refuses. + /// + /// +#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry + public static ValueTask> Increment( + this in RespStrings strings, + RedisKey key, + long value, + Expiration expiry, + long? lowerBound = null, + long? upperBound = null, + IncrementOptions options = IncrementOptions.None, + CommandFlags flags = CommandFlags.None) + { + ValidateIncrementExpiry(expiry); + + var cmd = strings.Context.Compose(RedisCommand.INCREX, argHint: 9); + try + { + cmd.Append($"{key}{RespLiterals.ByInt}{value}"); + if (lowerBound.HasValue) cmd.Append($"{RespLiterals.LBound}{lowerBound.GetValueOrDefault()}"); + if (upperBound.HasValue) cmd.Append($"{RespLiterals.UBound}{upperBound.GetValueOrDefault()}"); + cmd.Append($"{AsFragment(options)}{expiry}"); + } + catch + { + cmd.Dispose(); + throw; + } + + var frame = cmd.Complete(); + return strings.Context.SendAsync(ref frame, flags.WithDefaultCategory(RedisCommand.INCREX), RespHandlers.Inbuilt>.Require()); + } +#pragma warning restore RS0026 + + /// + /// The string command group. + /// The key to increment. + /// The amount to add. + /// When the key should expire; ENX applies it only to a new key. + /// The lowest value the result may take, if any. + /// The highest value the result may take, if any. + /// Whether a bound clamps the result or rejects the increment. + /// Command flags. +#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry + public static ValueTask> Increment( + this in RespStrings strings, + RedisKey key, + double value, + Expiration expiry, + double? lowerBound = null, + double? upperBound = null, + IncrementOptions options = IncrementOptions.None, + CommandFlags flags = CommandFlags.None) + { + ValidateIncrementExpiry(expiry); + + var cmd = strings.Context.Compose(RedisCommand.INCREX, argHint: 9); + try + { + cmd.Append($"{key}{RespLiterals.ByFloat}{value}"); + if (lowerBound.HasValue) cmd.Append($"{RespLiterals.LBound}{lowerBound.GetValueOrDefault()}"); + if (upperBound.HasValue) cmd.Append($"{RespLiterals.UBound}{upperBound.GetValueOrDefault()}"); + cmd.Append($"{AsFragment(options)}{expiry}"); + } + catch + { + cmd.Dispose(); + throw; + } + + var frame = cmd.Complete(); + return strings.Context.SendAsync(ref frame, flags.WithDefaultCategory(RedisCommand.INCREX), RespHandlers.Inbuilt>.Require()); + } +#pragma warning restore RS0026 + + /// LCS: the longest common subsequence of two keys' values. + /// The string command group. + /// The first key. + /// The second key. + /// Command flags. + public static ValueTask LongestCommonSubsequence(this in RespStrings strings, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.LCS}{first}{second}", flags.WithDefaultCategory(RedisCommand.LCS)); + + /// LCS ... LEN: the length of the longest common subsequence, without transferring it. + /// The string command group. + /// The first key. + /// The second key. + /// Command flags. + public static ValueTask LongestCommonSubsequenceLength(this in RespStrings strings, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.LCS}{first}{second}{RespLiterals.Len}", flags.WithDefaultCategory(RedisCommand.LCS)); + + /// LCS ... IDX: where the matches are, rather than what they contain. + /// The string command group. + /// The first key. + /// The second key. + /// Matches shorter than this are not reported. + /// Command flags. + public static ValueTask LongestCommonSubsequenceWithMatches( + this in RespStrings strings, + RedisKey first, + RedisKey second, + long minLength = 0, + CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync( + $"{RedisCommand.LCS}{first}{second}{RespLiterals.Idx}{RespLiterals.MinMatchLen}{minLength}{RespLiterals.WithMatchLen}", + flags.WithDefaultCategory(RedisCommand.LCS), + RespHandlers.Inbuilt.Require()); + + /// + /// Reject a condition this command has no spelling for, reusing ValueCondition's own + /// message so the two surfaces say the same thing. + /// + /// The return type of the call site, which never receives a value. + private static T ThrowUnsupportedCondition(in ValueCondition when, string operation) + { + when.ThrowInvalidOperation(operation); + return default!; // not reached; ThrowInvalidOperation always throws + } + + /// The SATURATE token, or nothing; an unknown option is a mistake, not a no-op. + private static RespFragment AsFragment(IncrementOptions options) => options switch + { + IncrementOptions.None => default, // a zero-argument fragment: written, contributes nothing + IncrementOptions.Saturate => RespLiterals.Saturate, + _ => throw new ArgumentOutOfRangeException(nameof(options)), + }; + + /// + /// The expirations INCREX has no spelling for. Mirrors RedisDatabase.ValidateStringIncrementExpiry, + /// which is the same list for the same command. + /// + private static void ValidateIncrementExpiry(Expiration expiry) + { + if (expiry.IsKeepTtl) throw new ArgumentException("KEEPTTL is not supported by this operation.", nameof(expiry)); + if (expiry.IsPersist) throw new ArgumentException("PERSIST is not supported by this operation.", nameof(expiry)); + if (expiry.IsExpireIfNotExists && !(expiry.IsAbsolute || expiry.IsRelative)) + { + throw new ArgumentException("ENX requires EX, PX, EXAT, or PXAT.", nameof(expiry)); + } + } } } diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs index 5c0b46b62..962193aeb 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace StackExchange.Redis.Interpolated @@ -69,5 +70,228 @@ public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expir /// public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) => StringSetAsync(key, value, expiry, keepTtl: false, when, flags); + + /// + public bool StringSet(KeyValuePair[] values, When when, CommandFlags flags) + => StringSet(values, when, Expiration.Default, flags); + + /// + public Task StringSetAsync(KeyValuePair[] values, When when, CommandFlags flags) + => StringSetAsync(values, when, Expiration.Default, flags); + + /// + public bool StringSet(KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Set(Required(values, nameof(values)), expiry, when, flags)); + + /// + public Task StringSetAsync(KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => Context.Strings.Set(Required(values, nameof(values)), expiry, when, flags).AsTask(); + + /// + public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => StringSetAndGet(key, value, expiry, keepTtl: false, when, flags); + + /// + public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => StringSetAndGetAsync(key, value, expiry, keepTtl: false, when, flags); + + /// + public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.SetAndGet(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags)); + + /// + public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Context.Strings.SetAndGet(key, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags).AsTask(); + + /// + /// + /// GETSET has been deprecated since 6.2; the group emits SET ... GET, which is the + /// same request with the same reply. Callers of the old name keep working and get the modern + /// spelling on the wire. + /// + public RedisValue StringGetSet(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.SetAndGet(key, value, flags: flags)); + + /// + public Task StringGetSetAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Strings.SetAndGet(key, value, flags: flags).AsTask(); + + /// + public RedisValue[] StringGet(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Get(Required(keys, nameof(keys)), flags)); + + /// + public Task StringGetAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Context.Strings.Get(Required(keys, nameof(keys)), flags).AsTask(); + + /// + public Lease? StringGetLease(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.GetLease(key, flags)); + + /// + public Task?> StringGetLeaseAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.GetLease(key, flags).AsTask(); + + /// + public RedisValue StringGetRange(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.GetRange(key, start, end, flags)); + + /// + public Task StringGetRangeAsync(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) + => Context.Strings.GetRange(key, start, end, flags).AsTask(); + + /// + public RedisValue StringGetDelete(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.GetDelete(key, flags)); + + /// + public Task StringGetDeleteAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.GetDelete(key, flags).AsTask(); + + // GETEX: two overloads and a separate PERSIST concept on the old surface, one Expiration on the + // new one. CreateOrPersist with !expiry.HasValue is the same mapping RedisDatabase uses - a null + // TimeSpan means "clear the TTL", which is NOT the same as Expiration.Default's "leave it alone" + + /// + public RedisValue StringGetSetExpiry(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.GetSetExpiry(key, Expiration.CreateOrPersist(expiry, !expiry.HasValue), flags)); + + /// + public Task StringGetSetExpiryAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) + => Context.Strings.GetSetExpiry(key, Expiration.CreateOrPersist(expiry, !expiry.HasValue), flags).AsTask(); + + /// + public RedisValue StringGetSetExpiry(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.GetSetExpiry(key, new Expiration(expiry), flags)); + + /// + public Task StringGetSetExpiryAsync(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Context.Strings.GetSetExpiry(key, new Expiration(expiry), flags).AsTask(); + + /// + public long StringAppend(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Append(key, value, flags)); + + /// + public Task StringAppendAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Strings.Append(key, value, flags).AsTask(); + + /// + public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Length(key, flags)); + + /// + public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.Length(key, flags).AsTask(); + + // SETRANGE replies with an integer; the old signature says RedisValue, so the conversion happens + // here rather than the group pretending not to know what it read + + /// + public RedisValue StringSetRange(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.SetRange(key, offset, value, flags)); + + /// + public async Task StringSetRangeAsync(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) + => await Context.Strings.SetRange(key, offset, value, flags).ConfigureAwait(false); + + /// + public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Delete(key, when, flags)); + + /// + public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + => Context.Strings.Delete(key, when, flags).AsTask(); + + /// + public ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Digest(key, flags)); + + /// + public Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Strings.Digest(key, flags).AsTask(); + + /// + public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Increment(key, value, flags)); + + /// + public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => Context.Strings.Increment(key, value, flags).AsTask(); + + /// + public double StringIncrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Increment(key, value, flags)); + + /// + public Task StringIncrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => Context.Strings.Increment(key, value, flags).AsTask(); + + // Decrement is a negation, here as it already was in RedisDatabase - the group has no DECRBY, + // because DECRBY n and INCRBY -n are the same request with the same reply + + /// + public long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => StringIncrement(key, -value, flags); + + /// + public Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => StringIncrementAsync(key, -value, flags); + + /// + public double StringDecrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => StringIncrement(key, -value, flags); + + /// + public Task StringDecrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => StringIncrementAsync(key, -value, flags); + + /// + public StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Increment(key, value, expiry, lowerBound, upperBound, options, flags)); + + /// + public Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => Context.Strings.Increment(key, value, expiry, lowerBound, upperBound, options, flags).AsTask(); + + /// + public StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.Increment(key, value, expiry, lowerBound, upperBound, options, flags)); + + /// + public Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => Context.Strings.Increment(key, value, expiry, lowerBound, upperBound, options, flags).AsTask(); + + /// + public string? StringLongestCommonSubsequence(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.LongestCommonSubsequence(first, second, flags)); + + /// + public Task StringLongestCommonSubsequenceAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Context.Strings.LongestCommonSubsequence(first, second, flags).AsTask(); + + /// + public long StringLongestCommonSubsequenceLength(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.LongestCommonSubsequenceLength(first, second, flags)); + + /// + public Task StringLongestCommonSubsequenceLengthAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Context.Strings.LongestCommonSubsequenceLength(first, second, flags).AsTask(); + + /// + public LCSMatchResult StringLongestCommonSubsequenceWithMatches(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) + => Wait(Context.Strings.LongestCommonSubsequenceWithMatches(first, second, minLength, flags)); + + /// + public Task StringLongestCommonSubsequenceWithMatchesAsync(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) + => Context.Strings.LongestCommonSubsequenceWithMatches(first, second, minLength, flags).AsTask(); + + /// + /// The old surface throws on a null array where a span would quietly be empty; keep throwing, + /// because "you passed null" and "you passed nothing" are different mistakes. + /// + /// The element type. + private static T[] Required(T[] values, string name) + => values ?? throw new ArgumentNullException(name); } } From 2072c771661cf4d6581c2ca3ae0507efacfb1321 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:25 +0100 Subject: [PATCH 101/360] Bitmaps as a group of their own GETBIT/SETBIT/BITCOUNT/BITPOS/BITOP/BITFIELD, under ctx.Bitmaps rather than in ctx.Strings. A bitmap is physically a string on the server and the old surface says so by name - StringBitCount, StringSetBit - but the whole argument for grouping (design notes 9.4, reason 1) is that the shape of the API should teach the API. Someone reaching for BITPOS is thinking about bitmaps, not about where the bytes live. The adapters keep the StringBitXxx names working, so the regrouping costs existing callers nothing. BYTE is never emitted: it is the server's own default, which a zero-argument RespFragment spells exactly, so the index type costs no branch at the call site. An open-ended BITPOS range with a bit index is rejected rather than silently reinterpreting `start` - the server takes the token only after an explicit end. BITFIELD is the one command here that cannot be a single expression, and not because of arity: OVERFLOW is sticky across sub-operations, so what an operation writes depends on what the previous one left in force. That is state a hole cannot carry, so it composes and loops - which is what Compose is for. The tokens move onto BitFieldOperation and are shared with the MessageWriter path. An all-GET payload goes out as BITFIELD_RO, without which a replica refuses it outright. The old surface decides this from the chosen endpoint's version; a context knows its command map but not the endpoint, so the test here is the map alone - which makes disabling BITFIELD_RO in the map the way to support a server older than 6.2, the same escape hatch as for any other missing command. --- src/StackExchange.Redis/BitFieldEncoding.cs | 2 +- src/StackExchange.Redis/BitFieldOffset.cs | 2 +- .../BitFieldOperation.Message.cs | 41 +-- .../BitFieldOperation.Resp.cs | 173 +++++++++ src/StackExchange.Redis/BitFieldOperation.cs | 2 +- .../Interpolated/RespSurface.Bitmaps.cs | 345 ++++++++++++++++++ .../TransitionalDatabase.Bitmaps.cs | 117 ++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 44 ++- 8 files changed, 692 insertions(+), 34 deletions(-) create mode 100644 src/StackExchange.Redis/BitFieldOperation.Resp.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Bitmaps.cs diff --git a/src/StackExchange.Redis/BitFieldEncoding.cs b/src/StackExchange.Redis/BitFieldEncoding.cs index eb1501fa5..b95a7f641 100644 --- a/src/StackExchange.Redis/BitFieldEncoding.cs +++ b/src/StackExchange.Redis/BitFieldEncoding.cs @@ -11,7 +11,7 @@ namespace StackExchange.Redis; /// server reports every value as a signed 64-bit integer. Every legal encoding therefore fits /// losslessly in . /// -public readonly struct BitFieldEncoding : IEquatable +public readonly partial struct BitFieldEncoding : IEquatable { // negative: signed; positive: unsigned; zero: default (not a legal encoding) private readonly sbyte _value; diff --git a/src/StackExchange.Redis/BitFieldOffset.cs b/src/StackExchange.Redis/BitFieldOffset.cs index 517192fb1..a18ba8b85 100644 --- a/src/StackExchange.Redis/BitFieldOffset.cs +++ b/src/StackExchange.Redis/BitFieldOffset.cs @@ -9,7 +9,7 @@ namespace StackExchange.Redis; /// the same width, which the server multiplies out for us (the # form). /// /// -public readonly struct BitFieldOffset : IEquatable +public readonly partial struct BitFieldOffset : IEquatable { private readonly long _value; private readonly bool _isElement; diff --git a/src/StackExchange.Redis/BitFieldOperation.Message.cs b/src/StackExchange.Redis/BitFieldOperation.Message.cs index 4b2ba6b60..c89899935 100644 --- a/src/StackExchange.Redis/BitFieldOperation.Message.cs +++ b/src/StackExchange.Redis/BitFieldOperation.Message.cs @@ -88,44 +88,25 @@ protected static void WriteOperations(in MessageWriter writer, ReadOnlySpan protected static void WriteOperation(in MessageWriter writer, in BitFieldOperation operation, ref BitFieldOverflow overflow, Span scratch) { + // the tokens live on BitFieldOperation, shared with the interpolated writer; see + // BitFieldOperation.Resp.cs if (operation.Kind != BitFieldOperation.OperationKind.Get && operation.Overflow != overflow) { overflow = operation.Overflow; - writer.WriteRaw("$8\r\nOVERFLOW\r\n"u8); - switch (overflow) - { - case BitFieldOverflow.Saturate: - writer.WriteRaw("$3\r\nSAT\r\n"u8); - break; - case BitFieldOverflow.Fail: - writer.WriteRaw("$4\r\nFAIL\r\n"u8); - break; - default: - // shape-neutral (the count comes from the transition, not the mode), so - // the server's own default is the safe answer here - writer.WriteRaw("$4\r\nWRAP\r\n"u8); - break; - } + writer.WriteRaw(BitFieldOperation.OverflowResp(overflow)); } - switch (operation.Kind) + var kind = operation.KindResp; + if (kind.IsEmpty) { - case BitFieldOperation.OperationKind.Get: - writer.WriteRaw("$3\r\nGET\r\n"u8); - break; - case BitFieldOperation.OperationKind.Set: - writer.WriteRaw("$3\r\nSET\r\n"u8); - break; - case BitFieldOperation.OperationKind.IncrementBy: - writer.WriteRaw("$6\r\nINCRBY\r\n"u8); - break; - default: - // unreachable: the callers check the operations before writing the header. - // Guessing here would be worse than failing - a wrong sub-command corrupts - // data, and one of the wrong arity corrupts the connection - throw new InvalidOperationException($"A default {nameof(BitFieldOperation)} is not a valid operation."); + // unreachable: the callers check the operations before writing the header. + // Guessing here would be worse than failing - a wrong sub-command corrupts + // data, and one of the wrong arity corrupts the connection + throw new InvalidOperationException($"A default {nameof(BitFieldOperation)} is not a valid operation."); } + writer.WriteRaw(kind); + operation.Encoding.Write(in writer, scratch); operation.Offset.Write(in writer, scratch); if (operation.Kind != BitFieldOperation.OperationKind.Get) diff --git a/src/StackExchange.Redis/BitFieldOperation.Resp.cs b/src/StackExchange.Redis/BitFieldOperation.Resp.cs new file mode 100644 index 000000000..2d623750a --- /dev/null +++ b/src/StackExchange.Redis/BitFieldOperation.Resp.cs @@ -0,0 +1,173 @@ +using System; +using StackExchange.Redis.Interpolated; + +namespace StackExchange.Redis; + +public readonly partial struct BitFieldOperation +{ + /// + /// The already-framed sub-command token - GET, SET, INCRBY - or empty for a + /// default operation, which is not a valid one. + /// + /// + /// Shared by both writers - the MessageWriter path and the interpolated one - rather than + /// restated in each; the same arrangement uses, and for the same reason. + /// + internal ReadOnlySpan KindResp => _kind switch + { + OperationKind.Get => "$3\r\nGET\r\n"u8, + OperationKind.Set => "$3\r\nSET\r\n"u8, + OperationKind.IncrementBy => "$6\r\nINCRBY\r\n"u8, + _ => default, + }; + + /// + /// The already-framed OVERFLOW <mode> pair - two arguments, emitted only when the sticky + /// mode changes. + /// + /// + /// WRAP for anything unrecognised: the argument count comes from the transition rather than + /// from the mode, so the shape is the same either way, and the server's own default is the safe answer. + /// + internal static ReadOnlySpan OverflowResp(BitFieldOverflow overflow) => overflow switch + { + BitFieldOverflow.Saturate => "$8\r\nOVERFLOW\r\n$3\r\nSAT\r\n"u8, + BitFieldOverflow.Fail => "$8\r\nOVERFLOW\r\n$4\r\nFAIL\r\n"u8, + _ => "$8\r\nOVERFLOW\r\n$4\r\nWRAP\r\n"u8, + }; + + /// + /// Write this operation into a command being composed, emitting the OVERFLOW pair only when + /// the sticky mode changes. + /// + /// The command being written. + /// The mode currently in force; updated when this operation changes it. + /// + /// Not an , and it cannot be one: the OVERFLOW mode is sticky + /// across the operations of one BITFIELD, so an operation does not know what it has to write + /// until it is told what the previous one left in force. An interface whose method takes only the + /// handler has nowhere to put that, which is why this is a plain internal method and why the group + /// method loops rather than passing a span into a hole. + /// + internal void WriteTo(scoped ref RespCommandHandler handler, ref BitFieldOverflow overflow) + { + if (_kind != OperationKind.Get && Overflow != overflow) + { + overflow = Overflow; +#pragma warning disable SER011 // pre-framed constants owned by this type; see Expiration for the reasoning + handler.AppendFormatted(new RespFragment(OverflowResp(overflow), argCount: 2)); +#pragma warning restore SER011 + } + + var kind = KindResp; + if (kind.IsEmpty) + { + // unreachable: the caller counts the operations before anything is written. Guessing here + // would be worse than failing - a wrong sub-command corrupts data, one of the wrong arity + // corrupts the connection + throw new InvalidOperationException($"A default {nameof(BitFieldOperation)} is not a valid operation."); + } + +#pragma warning disable SER011 // as above + handler.AppendFormatted(new RespFragment(kind)); +#pragma warning restore SER011 + Encoding.WriteTo(ref handler); + Offset.WriteTo(ref handler); + if (_kind != OperationKind.Get) handler.AppendFormatted(Value); + } + + /// How many RESP arguments a run of operations writes, including the key. + /// The operations to count. + /// The caller's parameter name, for the exception. + internal static int CountArgs(ReadOnlySpan operations, string paramName) + { + var count = 1; // the key + var overflow = BitFieldOverflow.Wrap; + foreach (ref readonly var op in operations) + { + count += op.CountArgs(ref overflow, paramName); + } + + return count; + } + + /// + internal static int CountArgs(in BitFieldOperation operation, string paramName) + { + var overflow = BitFieldOverflow.Wrap; + return 1 + operation.CountArgs(ref overflow, paramName); // the key, plus this operation + } + + /// How many arguments this one operation writes, given the mode currently in force. + private int CountArgs(ref BitFieldOverflow overflow, string paramName) + { + switch (_kind) + { + case OperationKind.Get: + return 3; // GET, encoding, offset + case OperationKind.Set: + case OperationKind.IncrementBy: + var count = 4; // SET/INCRBY, encoding, offset, value + if (Overflow != overflow) + { + overflow = Overflow; + count += 2; // OVERFLOW, mode + } + + return count; + default: + throw new ArgumentException($"A default {nameof(BitFieldOperation)} is not a valid operation.", paramName); + } + } +} + +public readonly partial struct BitFieldEncoding +{ + /// Write this encoding as one bulk string - i8, u63 - into a command being composed. + /// The command being written. + internal void WriteTo(scoped ref RespCommandHandler handler) + { + if (IsDefault) + { + throw new ArgumentException( + $"A {nameof(BitFieldEncoding)} must be created via {nameof(Signed)}, {nameof(Unsigned)}, or one of the named encodings.", + nameof(BitFieldEncoding)); + } + + // payload only: the handler frames what it is given, unlike the MessageWriter path, which is + // handed bytes that already carry their own $len + Span payload = stackalloc byte[3]; // sign, plus at most two digits: widths run 1-64 + int width = Width, len = 1; + payload[0] = IsSigned ? (byte)'i' : (byte)'u'; + if (width >= 10) + { + payload[len++] = (byte)('0' + (width / 10)); + payload[len++] = (byte)('0' + (width % 10)); + } + else + { + payload[len++] = (byte)('0' + width); + } + + handler.AppendBulk(payload.Slice(0, len)); + } +} + +public readonly partial struct BitFieldOffset +{ + /// Write this offset - a bit position, or the # element form - into a command being composed. + /// The command being written. + internal void WriteTo(scoped ref RespCommandHandler handler) + { + if (!_isElement) + { + handler.AppendFormatted(_value); + return; + } + + Span payload = stackalloc byte[Format.MaxInt64TextLen + 1]; + payload[0] = (byte)'#'; + var len = Format.FormatInt64(_value, payload.Slice(1)) + 1; + handler.AppendBulk(payload.Slice(0, len)); + } +} diff --git a/src/StackExchange.Redis/BitFieldOperation.cs b/src/StackExchange.Redis/BitFieldOperation.cs index 13234122b..445f63b0b 100644 --- a/src/StackExchange.Redis/BitFieldOperation.cs +++ b/src/StackExchange.Redis/BitFieldOperation.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis; /// . /// /// -public readonly struct BitFieldOperation : IEquatable +public readonly partial struct BitFieldOperation : IEquatable { internal enum OperationKind : byte { diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs new file mode 100644 index 000000000..6669e0ea9 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs @@ -0,0 +1,345 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The bitmap-command group: target.Bitmaps.Count(...). + /// + /// + /// + /// Its own group, not a corner of . A bitmap is physically a string on + /// the server, and the old surface says so by name - StringBitCount, StringSetBit. But + /// the whole argument for grouping (design notes 9.4, reason 1) is that the shape of the API should + /// teach the API, and Redis documents bitmaps as a type of their own with its own page. A caller + /// reaching for BITPOS is thinking about bitmaps, not about the fact that the bytes live in a + /// string key; ctx.Bitmaps. is the list they wanted, and ctx.Strings. stays the list + /// someone storing a value wanted. + /// + /// + /// The adapters in TransitionalDatabase.Bitmaps.cs keep the old StringBitXxx names + /// working, so the regrouping costs existing callers nothing. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespBitmaps + { + private readonly RespContext _context; + + /// Group the bitmap commands of a context. + /// The context to send through. + public RespBitmaps(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The bitmap commands. + public RespBitmaps Bitmaps => new(target.Context); + } + + extension(in RespContext context) + { + /// The bitmap commands. + public RespBitmaps Bitmaps => new(context); + } + + /// GETBIT. + /// The bitmap command group. + /// The key to read. + /// The bit offset. + /// Command flags. +#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types + public static ValueTask Get(this in RespBitmaps bitmaps, RedisKey key, long offset, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => bitmaps.Context.SendAsync( + $"{RedisCommand.GETBIT}{key}{offset}", flags.WithDefaultCategory(RedisCommand.GETBIT)); + + /// SETBIT; the reply is the bit that was there before. + /// The bitmap command group. + /// The key to write. + /// The bit offset; the value is zero-extended up to it. + /// The bit to set. + /// Command flags. +#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types + public static ValueTask Set(this in RespBitmaps bitmaps, RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => bitmaps.Context.SendAsync( + $"{RedisCommand.SETBIT}{key}{offset}{bit}", flags.WithDefaultCategory(RedisCommand.SETBIT)); + + /// BITCOUNT: how many bits are set, over a range. + /// The bitmap command group. + /// The key to count. + /// The inclusive start of the range; negative counts back from the end. + /// The inclusive end of the range; negative counts back from the end. + /// Whether the range is in bytes or in bits. + /// Command flags. + /// + /// The index type is a token on the wire and nothing at all when it is the default, which is + /// exactly what a zero-argument spells - so the whole command is one + /// expression with no branch at the call site. BYTE is not omitted to save bytes but + /// because it is the server's own default, and older servers do not accept the token at all. + /// + public static ValueTask Count( + this in RespBitmaps bitmaps, + RedisKey key, + long start = 0, + long end = -1, + StringIndexType indexType = StringIndexType.Byte, + CommandFlags flags = CommandFlags.None) + => bitmaps.Context.SendAsync( + $"{RedisCommand.BITCOUNT}{key}{start}{end}{AsFragment(indexType)}", + flags.WithDefaultCategory(RedisCommand.BITCOUNT)); + + /// BITPOS: the offset of the first bit with the given value. + /// The bitmap command group. + /// The key to search. + /// The bit value to look for. + /// The inclusive start of the range; negative counts back from the end. + /// + /// The inclusive end of the range; negative counts back from the end, and + /// leaves the range open, which is not the same thing when + /// is . + /// + /// Whether the range is in bytes or in bits. + /// Command flags. + /// + /// An open-ended range and a bit index cannot be combined: the server takes the BYTE/BIT token only + /// after an explicit end, so there is nowhere to put it. Dropping it silently would + /// reinterpret as a byte offset, which is why this says so instead. + /// + public static ValueTask Position( + this in RespBitmaps bitmaps, + RedisKey key, + bool bit, + long start = 0, + long end = -1, + StringIndexType indexType = StringIndexType.Byte, + CommandFlags flags = CommandFlags.None) + { + if (end == StringIndex.Unbounded) + { + if (indexType != StringIndexType.Byte) + { + throw new ArgumentException( + $"{nameof(StringIndex)}.{nameof(StringIndex.Unbounded)} requires {nameof(StringIndexType)}.{nameof(StringIndexType.Byte)};" + + " the server accepts a bit/byte index type only after an explicit end.", + nameof(indexType)); + } + + return bitmaps.Context.SendAsync( + $"{RedisCommand.BITPOS}{key}{bit}{start}", flags.WithDefaultCategory(RedisCommand.BITPOS)); + } + + return bitmaps.Context.SendAsync( + $"{RedisCommand.BITPOS}{key}{bit}{start}{end}{AsFragment(indexType)}", + flags.WithDefaultCategory(RedisCommand.BITPOS)); + } + + /// BITOP: combine bitmaps into a destination key; the reply is the destination's length. + /// The bitmap command group. + /// The operation to apply. + /// The key to write the result to. + /// The source keys. + /// Command flags. + /// + /// + /// One method where the old surface has two. The (first, second) overload exists only + /// because building a variadic message used to be work; with {keys} as a hole it is the same + /// expression either way, so the fixed-arity spelling has nothing left to offer. Note that the old + /// one also has a trap this does not - a default second silently means "unary". + /// + /// + /// takes exactly one source key, and every other operation takes at least + /// one; both are checked here rather than left to the server, because the failure is a round trip + /// away from the mistake. + /// + /// + public static ValueTask Operation( + this in RespBitmaps bitmaps, + Bitwise operation, + RedisKey destination, + ReadOnlySpan keys, + CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one source key is required.", nameof(keys)); + if (operation == Bitwise.Not && keys.Length != 1) + { + throw new ArgumentException("BITOP NOT takes exactly one source key.", nameof(keys)); + } + + return bitmaps.Context.SendAsync( + $"{RedisCommand.BITOP}{AsFragment(operation)}{destination}{keys}", + flags.WithDefaultCategory(RedisCommand.BITOP)); + } + + /// BITFIELD: several sub-operations against one key, in one command. + /// The bitmap command group. + /// The key to operate on. + /// The sub-operations, in order; the reply has one element per operation. + /// Command flags. + /// + /// + /// The one command in this group that cannot be a single interpolated expression, and not because + /// of its arity: OVERFLOW is sticky, so whether an operation writes it depends on + /// what the previous one left in force. That is state a hole cannot carry, so this composes and + /// loops - which is exactly what Compose exists for. + /// + /// + /// An all-GET payload goes out as BITFIELD_RO, which is what makes a replica willing + /// to serve it - BITFIELD is a write command to the server however read-only its + /// sub-operations are, and a replica rejects it outright. The old surface decides this by asking + /// the chosen endpoint's version; a context knows its command map but not the endpoint, so the + /// test here is the map alone. Disabling BITFIELD_RO in the map is therefore the way to + /// support a server older than 6.2, and is the same escape hatch that covers every other command + /// a given server does not have. + /// + /// + /// The lease must be disposed. A nil element means that operation was skipped by + /// OVERFLOW FAIL. + /// + /// +#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types + public static ValueTask> Field( + this in RespBitmaps bitmaps, + RedisKey key, + ReadOnlySpan operations, + CommandFlags flags = CommandFlags.None) + { + if (operations.IsEmpty) return new ValueTask>(Lease.Empty); + + // counted up front, so a default operation throws at the caller rather than mid-write + var argCount = BitFieldOperation.CountArgs(operations, nameof(operations)); + var command = SelectCommand(bitmaps.Context, operations, ref flags); + + var cmd = bitmaps.Context.Compose(command, argCount); + try + { + cmd.AppendFormatted(key); + var overflow = BitFieldOverflow.Wrap; + foreach (ref readonly var operation in operations) + { + operation.WriteTo(ref cmd, ref overflow); + } + } + catch + { + cmd.Dispose(); + throw; + } + + var frame = cmd.Complete(); + return bitmaps.Context.SendAsync(ref frame, flags, RespHandlers.Inbuilt>.Require()); + } +#pragma warning restore RS0026 + + /// BITFIELD with a single sub-operation, whose reply is one value rather than a run. + /// The bitmap command group. + /// The key to operate on. + /// The sub-operation. + /// Command flags. + /// + /// The same bytes as the span form with one element, unwrapped from the array the server always + /// replies with - so the common case costs neither a lease nor a disposal. + /// means the operation was skipped by OVERFLOW FAIL. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the third parameter + public static ValueTask Field(this in RespBitmaps bitmaps, RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + { + // deliberately NOT a one-element span: BitFieldOperation holds a RedisValue, so it cannot be + // stackalloc'd, and the span-from-a-single-value constructor does not exist on every target + var argCount = BitFieldOperation.CountArgs(in operation, nameof(operation)); + var command = SelectCommand( + bitmaps.Context, + allGet: operation.Kind == BitFieldOperation.OperationKind.Get, + anyIncrement: operation.Kind == BitFieldOperation.OperationKind.IncrementBy, + ref flags); + + var cmd = bitmaps.Context.Compose(command, argCount); + try + { + cmd.AppendFormatted(key); + var overflow = BitFieldOverflow.Wrap; + operation.WriteTo(ref cmd, ref overflow); + } + catch + { + cmd.Dispose(); + throw; + } + + var frame = cmd.Complete(); + return bitmaps.Context.SendAsync(ref frame, flags, RespHandlers.NullableInt64); + } + + /// + /// Which BITFIELD to send, and what retry category it deserves - both of which depend on the + /// payload rather than on the command's name. + /// + /// + /// The decision itself is RedisDatabase.SelectBitFieldCommand, shared rather than restated: + /// all-GET is a pure read whatever command carries it, a payload of SETs replays to the same end + /// state, and only INCRBY compounds. Only the "is the read-only command available?" input differs, + /// because here it is answered by the command map instead of by the endpoint's version. + /// + private static RedisCommand SelectCommand(in RespContext context, ReadOnlySpan operations, ref CommandFlags flags) + { + bool allGet = true, anyIncrement = false; + foreach (ref readonly var operation in operations) + { + switch (operation.Kind) + { + case BitFieldOperation.OperationKind.Get: + break; + case BitFieldOperation.OperationKind.IncrementBy: + anyIncrement = true; + allGet = false; + break; + default: + allGet = false; + break; + } + } + + return SelectCommand(in context, allGet, anyIncrement, ref flags); + } + + /// + private static RedisCommand SelectCommand(in RespContext context, bool allGet, bool anyIncrement, ref CommandFlags flags) + { + var readOnlyAvailable = allGet && context.CommandMap.IsAvailable(RedisCommand.BITFIELD_RO); + var command = RedisDatabase.SelectBitFieldCommand(allGet, anyIncrement, readOnlyAvailable, ref flags); + flags = flags.WithDefaultCategory(command); + return command; + } + + /// The BYTE/BIT index token; BYTE is the server's default and renders nothing. + private static RespFragment AsFragment(StringIndexType indexType) => indexType switch + { + StringIndexType.Byte => default, // a zero-argument fragment: written, contributes nothing + StringIndexType.Bit => RespLiterals.Bit, + _ => throw new ArgumentOutOfRangeException(nameof(indexType)), + }; + + /// The BITOP operation token. + private static RespFragment AsFragment(Bitwise operation) => operation switch + { + Bitwise.And => RespLiterals.And, + Bitwise.Or => RespLiterals.Or, + Bitwise.Xor => RespLiterals.Xor, + Bitwise.Not => RespLiterals.Not, + Bitwise.Diff => RespLiterals.Diff, + Bitwise.Diff1 => RespLiterals.Diff1, + Bitwise.AndOr => RespLiterals.AndOr, + Bitwise.One => RespLiterals.One, + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + } +} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Bitmaps.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Bitmaps.cs new file mode 100644 index 000000000..8d635510f --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Bitmaps.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The bitmap commands, where they have moved to the RESP context surface. + /// + /// + /// + /// The old surface calls these StringBitXxx and the new one groups them under + /// ; this file is where the two names meet, and is the reason regrouping them + /// costs existing callers nothing. + /// + /// + /// Note what is not here: StringBitOperation(operation, destination, first, second) is + /// implemented in terms of the multi-key form, because the group has only the multi-key form. The + /// default second is the old surface's way of spelling a unary operation, and unpacking it here + /// is the whole of the difference. + /// + /// + internal sealed partial class TransitionalDatabase + { + /// + public bool StringGetBit(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Get(key, offset, flags)); + + /// + public Task StringGetBitAsync(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Get(key, offset, flags).AsTask(); + + /// + public bool StringSetBit(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Set(key, offset, bit, flags)); + + /// + public Task StringSetBitAsync(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Set(key, offset, bit, flags).AsTask(); + + /// + public long StringBitCount(RedisKey key, long start, long end, CommandFlags flags) + => StringBitCount(key, start, end, StringIndexType.Byte, flags); + + /// + public Task StringBitCountAsync(RedisKey key, long start, long end, CommandFlags flags) + => StringBitCountAsync(key, start, end, StringIndexType.Byte, flags); + + /// + public long StringBitCount(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Count(key, start, end, indexType, flags)); + + /// + public Task StringBitCountAsync(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Count(key, start, end, indexType, flags).AsTask(); + + /// + public long StringBitPosition(RedisKey key, bool bit, long start, long end, CommandFlags flags) + => StringBitPosition(key, bit, start, end, StringIndexType.Byte, flags); + + /// + public Task StringBitPositionAsync(RedisKey key, bool bit, long start, long end, CommandFlags flags) + => StringBitPositionAsync(key, bit, start, end, StringIndexType.Byte, flags); + + /// + public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Position(key, bit, start, end, indexType, flags)); + + /// + public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Position(key, bit, start, end, indexType, flags).AsTask(); + + /// + public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Operation(operation, destination, Sources(operation, first, second), flags)); + + /// + public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Operation(operation, destination, Sources(operation, first, second), flags).AsTask(); + + /// + public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Operation(operation, destination, Required(keys, nameof(keys)), flags)); + + /// + public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Operation(operation, destination, Required(keys, nameof(keys)), flags).AsTask(); + + /// + public long? StringBitField(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Field(key, operation, flags)); + + /// + public Task StringBitFieldAsync(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Field(key, operation, flags).AsTask(); + + /// + public Lease StringBitField(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None) + => Wait(Context.Bitmaps.Field(key, operations.Span, flags)); + + /// + public Task> StringBitFieldAsync(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None) + => Context.Bitmaps.Field(key, operations.Span, flags).AsTask(); + + /// + /// The old (first, second) shape as a run of source keys: a default second, or a NOT, + /// means unary. + /// + /// + /// operation == Bitwise.Not is part of the test in RedisDatabase too, and it is not + /// redundant: a caller who passes a second key to a NOT gets it ignored there, and would get an + /// argument exception from the group. Keeping the old behaviour for the old spelling is the point + /// of an adapter. + /// + private static RedisKey[] Sources(Bitwise operation, in RedisKey first, in RedisKey second) + => second.IsNull || operation == Bitwise.Not ? [first] : [first, second]; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index d246cfe57..f5aefff0e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -16,6 +16,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.RespAttribute(string! token, params string![]! additionalTokens) -> void [SER010]StackExchange.Redis.Interpolated.RespAttribute.Tokens.get -> string![]! +[SER010]StackExchange.Redis.Interpolated.RespBitmaps +[SER010]StackExchange.Redis.Interpolated.RespBitmaps.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespBitmaps.RespBitmaps() -> void +[SER010]StackExchange.Redis.Interpolated.RespBitmaps.RespBitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void @@ -50,6 +54,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendBulk(scoped System.ReadOnlySpan payload) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan> value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value, string? format) -> void [SER010]StackExchange.Redis.Interpolated.IRespArgument @@ -147,8 +153,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespStrings.RespStrings(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespSurface [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool @@ -163,12 +171,46 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment -[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Ok.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Boolean.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Double.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Int64.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Lease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.NullableInt64.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.String.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Success.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Values.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Append(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Digest(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, System.ReadOnlySpan operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLease(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetRange(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long start, long end, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.Expiration expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequence(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Operation(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.Bitwise operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan> values, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.SetAndGet(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.SetRange(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespBitmaps +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void From ac1368eb217991375c79024e44464f77f6da80cf Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:30 +0100 Subject: [PATCH 102/360] Let the existing suites be the proof StringTests and BitTests now run a second time, unchanged, against a TransitionalDatabase - so every command that has moved is checked by the assertions written for the old surface, against real servers, across every protocol the suite already covers. This is a stronger signal than a parallel suite would be. A hand-written test for a moved command asserts what its author believed the command does; re-running StringTests asserts what the library has always claimed it does, and keeps asserting it as more commands move, with no new test to write. Two small things make it possible. TestBase gains a virtual GetDatabase, which is the only change to the suites themselves - 43 identical call sites. TransitionalDatabase gains an optional fallback: given one, the AutoDatabase funnels invoke the captured operation against it instead of throwing, so moved commands go through the interpolated writer and everything still unmoved - KeyDelete, KeyExpire, Execute, the scans - forwards to the old implementation. A failure is therefore a failure of something that HAS moved. The fallback is deliberately opt-in and not the default: without one the throw is the point, and a production transitional database that silently forwarded would make "has this moved?" unanswerable. The only thing that opts in is a test harness. SER352 is down from 618 unimplemented members to 548. --- .../TransitionalDatabase.Scans.cs | 30 ++++---- .../Interpolated/TransitionalDatabase.cs | 57 ++++++++++---- tests/StackExchange.Redis.Tests/BitTests.cs | 12 +-- .../StackExchange.Redis.Tests/StringTests.cs | 74 +++++++++---------- tests/StackExchange.Redis.Tests/TestBase.cs | 13 ++++ .../TransitionalSurfaceTests.cs | 62 ++++++++++++++++ 6 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs index a2e5bfd34..40019114e 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Scans.cs @@ -7,47 +7,51 @@ namespace StackExchange.Redis.Interpolated; // capture-and-replay shape, so [AutoDatabase] skips them by category - they are the one part of the // interface that does have to be listed by hand, and the only part of this type that a new command could // oblige someone to touch. +// +// Like the generated members, these throw unless a fallback was supplied; see TransitionalDatabase._fallback. internal sealed partial class TransitionalDatabase { + private IDatabase Scans => _fallback ?? throw NotMoved(); + private static NotImplementedException NotMoved() => new("This command has not yet moved to the RESP context surface."); public IEnumerable HashScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => throw NotMoved(); + => Scans.HashScan(key, pattern, pageSize, flags); public IEnumerable HashScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.HashScan(key, pattern, pageSize, cursor, pageOffset, flags); public IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.HashScanNoValues(key, pattern, pageSize, cursor, pageOffset, flags); public IEnumerable SetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => throw NotMoved(); + => Scans.SetScan(key, pattern, pageSize, flags); public IEnumerable SetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.SetScan(key, pattern, pageSize, cursor, pageOffset, flags); public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => throw NotMoved(); + => Scans.SortedSetScan(key, pattern, pageSize, flags); public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.SortedSetScan(key, pattern, pageSize, cursor, pageOffset, flags); public IEnumerable VectorSetRangeEnumerate(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.VectorSetRangeEnumerate(key, start, end, count, exclude, flags); public IAsyncEnumerable HashScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.HashScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); public IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.HashScanNoValuesAsync(key, pattern, pageSize, cursor, pageOffset, flags); public IAsyncEnumerable SetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.SetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); public IAsyncEnumerable SortedSetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); public IAsyncEnumerable VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => throw NotMoved(); + => Scans.VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); } diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs index 9ac74edb7..de4a4376c 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.cs @@ -31,11 +31,30 @@ namespace StackExchange.Redis.Interpolated /// /// [AutoDatabase(WarnIfIncomplete = true)] - internal sealed partial class TransitionalDatabase(RespDatabase inner, IConnectionMultiplexer multiplexer, object? asyncState) + internal sealed partial class TransitionalDatabase(RespDatabase inner, IConnectionMultiplexer multiplexer, object? asyncState, IDatabase? fallback = null) : IDatabase { private readonly RespDatabase _inner = inner; + /// + /// An old-surface database to forward not-yet-moved commands to, or to throw. + /// + /// + /// + /// This is what lets the EXISTING test suite be the proof. Given a fallback, an instance of + /// this class is a drop-in that happens to route the moved commands through + /// the new write path and everything else through the old one - so StringTests runs + /// unmodified against it and every assertion in it becomes an assertion about the new surface. The + /// alternative is a parallel suite that re-states the same expectations and drifts. + /// + /// + /// Deliberately not the default: without a fallback the throw is the point (see the type + /// remarks), and a production transitional database that silently forwards would make "has this + /// moved?" unanswerable. It is opt-in, and the only thing that opts in is a test harness. + /// + /// + private readonly IDatabase? _fallback = fallback; + /// public RespContext Context => _inner.Context; @@ -49,24 +68,31 @@ internal sealed partial class TransitionalDatabase(RespDatabase inner, IConnecti public IConnectionMultiplexer Multiplexer => multiplexer; // ---- [AutoDatabase] funnels --------------------------------------------------------------------- - // Every member this class does not implement lands here. There is no inner IDatabase to replay - // against - that is the whole point - so the captured state is never invoked, and the throw names - // the member so the message says which command still needs moving. + // Every member this class does not implement lands here. Normally there is no inner IDatabase to + // forward to - that is the whole point - so the captured state is never invoked, and the throw + // names the member so the message says which command still needs moving. A test harness can supply + // a fallback, and then the capture is invoked against it exactly once; see _fallback. private TResult Execute(in TState state, AutoDatabaseSyncOperation operation) where TState : struct - => throw NotMoved(); + => _fallback is { } db ? operation(in state, db) : throw NotMoved(); private void Execute(in TState state, AutoDatabaseSyncOperation operation) where TState : struct - => throw NotMoved(); + { + if (_fallback is not { } db) throw NotMoved(); + operation(in state, db); + } private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) where TState : struct - => throw NotMoved(); + => _fallback is { } db ? operation(in state, db) : throw NotMoved(); private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) where TState : struct - => throw NotMoved(); + => _fallback is { } db ? operation(in state, db) : throw NotMoved(); + + /// The fallback, or a throw naming what is missing. + private IDatabase Fallback() => _fallback ?? throw NotMoved(); private static NotImplementedException NotMoved() => new($"This command has not yet moved to the RESP context surface (captured as '{typeof(TState).Name}')."); @@ -131,19 +157,24 @@ private void Wait(ValueTask pending) } // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- - public IBatch CreateBatch(object? asyncState = null) => throw new NotImplementedException(); + // These take the fallback too, so a harness that supplies one gets a complete IDatabase rather than + // one with holes in exactly the places a test suite reaches for scaffolding. + public IBatch CreateBatch(object? asyncState = null) + => Fallback().CreateBatch(asyncState); - public ITransaction CreateTransaction(object? asyncState = null) => throw new NotImplementedException(); + public ITransaction CreateTransaction(object? asyncState = null) + => Fallback().CreateTransaction(asyncState); ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); - public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) => throw new NotImplementedException(); + public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) + => Fallback().IsConnected(key, flags); public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => throw new NotImplementedException(); + => Fallback().IdentifyEndpoint(key, flags); public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => throw new NotImplementedException(); + => Fallback().IdentifyEndpointAsync(key, flags); // the Wait family operates on caller-supplied Tasks, not server calls #pragma warning disable SER308 // Blocking on a task through the library's Wait helpers diff --git a/tests/StackExchange.Redis.Tests/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs index 71f00b90d..020f2d672 100644 --- a/tests/StackExchange.Redis.Tests/BitTests.cs +++ b/tests/StackExchange.Redis.Tests/BitTests.cs @@ -13,7 +13,7 @@ public class BitTests(ITestOutputHelper output, SharedConnectionFixture fixture) public async Task BasicOps() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -26,7 +26,7 @@ public async Task BasicOps() public async Task BitPositionUnboundedEndLooksPastEndOfString() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -53,7 +53,7 @@ public async Task BitPositionUnboundedEndLooksPastEndOfString() public async Task BitFieldBasicOps() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -72,7 +72,7 @@ public async Task BitFieldBasicOps() public async Task BitFieldBatchAppliesInOrder() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -96,7 +96,7 @@ public async Task BitFieldBatchAppliesInOrder() public async Task BitFieldEmptyBatchIsANoOp() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); using var lease = db.StringBitField(key, ReadOnlyMemory.Empty); @@ -116,7 +116,7 @@ public async Task BitFieldAllGetGoesOutAsReadOnlyAndReachesAReplica() conn.GetEndPoints().Any(ep => conn.GetServer(ep).IsReplica), "No replica in this configuration"); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); RedisKey key = Me(); var session = new ProfilingSession(); conn.RegisterProfiler(() => session); diff --git a/tests/StackExchange.Redis.Tests/StringTests.cs b/tests/StackExchange.Redis.Tests/StringTests.cs index 2dcf8f6fb..b674f47e9 100644 --- a/tests/StackExchange.Redis.Tests/StringTests.cs +++ b/tests/StackExchange.Redis.Tests/StringTests.cs @@ -18,7 +18,7 @@ public async Task Append() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var server = GetServer(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -52,7 +52,7 @@ public async Task Set() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -71,7 +71,7 @@ public async Task SetEmpty() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -89,7 +89,7 @@ public async Task StringGetSetExpiryNoValue() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -103,7 +103,7 @@ public async Task StringGetSetExpiryRelative() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -122,7 +122,7 @@ public async Task StringGetSetExpiryAbsolute() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -146,7 +146,7 @@ public async Task StringGetSetExpiryPersist() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -163,7 +163,7 @@ public async Task GetLease() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -180,7 +180,7 @@ public async Task GetLeaseAsStream() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -202,7 +202,7 @@ public async Task GetDelete() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -224,7 +224,7 @@ public async Task GetDeleteAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -246,7 +246,7 @@ public async Task SetNotExists() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -282,7 +282,7 @@ public async Task SetKeepTtl() { await using var conn = Create(require: RedisFeatures.v6_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -320,7 +320,7 @@ public async Task SetAndGet() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -388,7 +388,7 @@ public async Task SetNotExistsAndGet() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); db.KeyDelete(prefix + "1", CommandFlags.FireAndForget); db.KeyDelete(prefix + "2", CommandFlags.FireAndForget); @@ -418,7 +418,7 @@ public async Task Ranges() { await using var conn = Create(require: RedisFeatures.v2_1_8); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -437,7 +437,7 @@ public async Task IncrDecr() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -464,7 +464,7 @@ public async Task IncrDecrFloat() { await using var conn = Create(require: RedisFeatures.v2_6_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -492,7 +492,7 @@ public async Task GetRange() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -509,7 +509,7 @@ public async Task BitCount() { await using var conn = Create(require: RedisFeatures.v2_6_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); db.StringSet(key, "foobar", flags: CommandFlags.FireAndForget); @@ -537,7 +537,7 @@ public async Task BitCountWithBitUnit() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); db.StringSet(key, "foobar", flags: CommandFlags.FireAndForget); @@ -561,7 +561,7 @@ public async Task BitOp() { await using var conn = Create(require: RedisFeatures.v2_6_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var key1 = prefix + "1"; var key2 = prefix + "2"; @@ -595,7 +595,7 @@ public async Task BitOp() public async Task BitOpExtended() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyX = prefix + "X"; var keyY1 = prefix + "Y1"; @@ -664,7 +664,7 @@ public async Task BitOpExtended() public async Task BitOpTwoOperands() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var key1 = prefix + "1"; var key2 = prefix + "2"; @@ -699,7 +699,7 @@ public async Task BitOpTwoOperands() public async Task BitOpDiff() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyX = prefix + "X"; var keyY1 = prefix + "Y1"; @@ -727,7 +727,7 @@ public async Task BitOpDiff() public async Task BitOpDiff1() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyX = prefix + "X"; var keyY1 = prefix + "Y1"; @@ -755,7 +755,7 @@ public async Task BitOpDiff1() public async Task BitOpAndOr() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyX = prefix + "X"; var keyY1 = prefix + "Y1"; @@ -783,7 +783,7 @@ public async Task BitOpAndOr() public async Task BitOpOne() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var key1 = prefix + "1"; var key2 = prefix + "2"; @@ -811,7 +811,7 @@ public async Task BitOpOne() public async Task BitOpDiffAsync() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyX = prefix + "X"; var keyY1 = prefix + "Y1"; @@ -837,7 +837,7 @@ public async Task BitOpDiffAsync() public async Task BitOpEdgeCases() { await using var conn = Create(require: RedisFeatures.v8_2_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var prefix = Me(); var keyEmpty = prefix + "empty"; var keyNonEmpty = prefix + "nonempty"; @@ -869,7 +869,7 @@ public async Task BitPosition() { await using var conn = Create(require: RedisFeatures.v2_6_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); db.StringSet(key, "foo", flags: CommandFlags.FireAndForget); @@ -897,7 +897,7 @@ public async Task BitPositionWithBitUnit() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); db.StringSet(key, "foo", flags: CommandFlags.FireAndForget); @@ -914,7 +914,7 @@ public async Task RangeString() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.StringSet(key, "hello world", flags: CommandFlags.FireAndForget); var result = db.StringGetRangeAsync(key, 2, 6); @@ -926,7 +926,7 @@ public async Task HashStringLengthAsync() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string value = "hello world"; db.HashSet(key, "field", value); @@ -941,7 +941,7 @@ public async Task HashStringLength() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string value = "hello world"; db.HashSet(key, "field", value); @@ -954,7 +954,7 @@ public async Task LongestCommonSubsequence() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; var key2 = Me() + "2"; db.KeyDelete(key1); @@ -994,7 +994,7 @@ public async Task LongestCommonSubsequenceAsync() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; var key2 = Me() + "2"; db.KeyDelete(key1); diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index c59039478..ef554e121 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -271,6 +271,19 @@ protected static IServer GetAnyPrimary(IConnectionMultiplexer muxer) throw new InvalidOperationException("Requires a primary endpoint (found none)"); } + /// + /// How a test reaches a database. Virtual so a fixture subclass can run the SAME tests against a + /// different implementation - see TransitionalStringTests, which points + /// it at the new RESP context surface. + /// + /// + /// Worth the indirection only because the alternative is a parallel test suite restating the same + /// expectations: an implementation swap that the existing assertions cannot tell apart is the strongest + /// evidence a rewrite can produce, and it stays true as the suite grows. + /// + protected virtual IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => conn.GetDatabase(db, asyncState); + internal virtual bool HighIntegrity => false; internal virtual Tunnel? Tunnel => _inProcServerFixture?.Tunnel; diff --git a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs new file mode 100644 index 000000000..be0414142 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs @@ -0,0 +1,62 @@ +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Runs an existing suite unchanged against TransitionalDatabase, so that every command the new +/// RESP context surface has taken over is checked by the assertions that were written for the old one. +/// +/// +/// +/// This is the proof the spike actually needs. A hand-written test for a moved command asserts what its +/// author believed the command does; re-running StringTests asserts what the library has always +/// claimed it does, against real servers, across every protocol the suite already covers - and it keeps +/// asserting it as commands keep moving, with no new test to write. +/// +/// +/// The database is a TransitionalDatabase with the ordinary one behind it: moved commands go +/// through the interpolated writer and the new handlers, and anything still unmoved - KeyDelete, +/// KeyExpire, Execute, the scans - forwards to the old implementation. So a failure here is +/// a failure of something that HAS moved, which is exactly the signal wanted. As more groups move, the +/// fallback carries less and the proof gets stronger on its own. +/// +/// +/// Note TestBase.Me() includes the test class name, so these runs use different keys from the base +/// class's and the two can run concurrently. +/// +/// +public abstract class TransitionalSurfaceFixture +{ + /// + /// The context surface as an , with the ordinary database as the fallback for + /// commands that have not moved yet. + /// + internal static IDatabase Wrap(IConnectionMultiplexer conn, int db, object? asyncState) + { + var inner = conn.GetDatabase(db, asyncState); + + // RedisDatabase.Context is already wired to a live executor (RespMessageExecutor), so the new + // surface reaches the same connection, the same backlog and the same multiplexing as the old one; + // the only thing that differs is how the bytes were produced and how the reply was read + return new TransitionalDatabase(new RespDatabase(inner.Context), conn, asyncState, inner); + } +} + +/// +[RunPerProtocol] +public class TransitionalStringTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : StringTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} + +/// +[RunPerProtocol] +public class TransitionalBitTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : BitTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} From a6083e2271dd82a8c9cdff953f750239b2a84497 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:36 +0100 Subject: [PATCH 103/360] Fire-and-forget: two bugs the re-run found immediately Both are on the fire-and-forget path, and neither was reachable from the hand-written tests because those all await a reply. **A NullReferenceException from inside an await.** The pipeline captures no reply for a fire-and-forget message, so the executor hands back null and every `handler.Parse(response.Span)` dereferenced it. The rule is now stated once, in one helper: no reply means default(TResult), which is exactly what ExecuteSync/ExecuteAsync have always returned for such a call. A cache fill that will never be filled is abandoned rather than left stranding its waiters. **A use-after-free that killed the connection.** The caller's RespRequest owns a pooled, reference-counted buffer and disposes it when its own call is done. For an ordinary command those orderings cannot cross - "done" means the reply arrived, which is after the write. Fire-and-forget completes the instant the message is queued, so the dispose handed the buffer back to the pool while the writer still held it: ObjectDisposedException inside WriteMessageToServerInsideWriteLock, which tears down the connection and fails every other command in flight on it. The symptom appeared three tests away from the cause, which is exactly why running a whole suite found it and a targeted test would not have. A fire-and-forget message now takes a plain copy of the bytes. Not a pooled one: a rented array would need returning, and "when is it safe to return this?" is the question that just went wrong. That path has already chosen throughput over bookkeeping, and one short-lived array is cheaper than a lifetime protocol. --- .../Interpolated/RespExecutor.cs | 52 +++++++++++++++---- .../Interpolated/RespMessageExecutor.cs | 38 ++++++++++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index edbd2c14c..bb4daa9bd 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -81,6 +81,26 @@ public interface IRespHandler [SuppressMessage("ApiDesign", "RS0027:API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "Overloads differ by parameter type; ambiguity is impossible")] public static class RespExecutor { + /// + /// Parse a reply, or produce the default when there was none. + /// + /// + /// + /// Fire-and-forget has no reply at all. The caller has explicitly declined it, so the + /// pipeline never captures one and the executor hands back - which is not an + /// error, and is why every path that reaches a handler goes through here rather than dereferencing + /// the payload. default is what the existing surface has always returned for such a call + /// (ExecuteSync/ExecuteAsync return default(T)), so the two agree. + /// + /// + /// Stated once, on purpose: there are five places a reply reaches a handler, and the cost of one of + /// them forgetting this is a from inside an await, + /// which says nothing about fire-and-forget to whoever has to read it. + /// + /// + private static TResult Parse(IRespHandler handler, RespPayload? response) + => response is null ? default! : handler.Parse(response.Span); + /// /// Send a request and parse the reply, optionally serving it from - and populating - the context's cache. /// @@ -142,12 +162,18 @@ public static TResult Send( try { + if (filled is null) + { + fill.Abandon(); // fire-and-forget: no reply is coming, so nothing can fill this + return default!; + } + cache.TryComplete(fill, filled); - return handler.Parse(filled.Span); + return Parse(handler, filled); } finally { - filled.Release(); + filled?.Release(); } } @@ -161,11 +187,11 @@ public static TResult Send( var response = executor.Send(owned); try { - return handler.Parse(response.Span); + return Parse(handler, response); } finally { - response.Release(); + response?.Release(); } } finally @@ -365,12 +391,18 @@ private static async ValueTask AwaitFill( try { + if (response is null) + { + fill.Abandon(); // fire-and-forget: no reply is coming, so nothing can fill this + return default!; + } + cache.TryComplete(fill, response); - return handler.Parse(response.Span); + return Parse(handler, response); } finally { - response.Release(); + response?.Release(); } } @@ -422,11 +454,11 @@ private static async ValueTask AwaitShared( var response = await executor.SendAsync(owned, cancellationToken).ConfigureAwait(false); try { - return handler.Parse(response.Span); + return Parse(handler, response); } finally { - response.Release(); + response?.Release(); } } finally @@ -446,11 +478,11 @@ private static async ValueTask AwaitUncached( var response = await executor.SendAsync(request, cancellationToken).ConfigureAwait(false); try { - return handler.Parse(response.Span); + return Parse(handler, response); } finally { - response.Release(); + response?.Release(); } } finally diff --git a/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs index 166d94b0e..ce0c9f5ff 100644 --- a/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Threading; using System.Threading.Tasks; @@ -57,14 +57,45 @@ public ValueTask SendAsync(RespRequest request, CancellationToken c } /// A message whose body is already framed: writing it is a blit. + /// + /// + /// Ownership, and the one case where a blit is not enough. The pipeline writes a message + /// some time after the caller regains control, and the caller's RespRequest owns a pooled, + /// reference-counted buffer that it disposes when its own call is done. For an ordinary command + /// those two orderings cannot cross: "the call is done" means the reply arrived, which is strictly + /// after the write. + /// + /// + /// Fire-and-forget breaks that. The caller has declined the reply, so its call completes the + /// instant the message is queued - and its dispose then hands the buffer back to the pool while it + /// is still sitting in the write queue. The symptom is not subtle but it is far away: an + /// from inside WriteMessageToServerInsideWriteLock, + /// which kills the connection and fails every other command in flight on it. Found by running the + /// existing test suite against this path, which is exactly what that exercise is for. + /// + /// + /// So a fire-and-forget message takes a plain copy of the bytes. Not a pooled one: a rented array + /// would need returning, and "when is it safe to return this?" is the question that just went + /// wrong. Fire-and-forget is the path that has already chosen throughput over bookkeeping, and one + /// short-lived array is a cheaper answer than a lifetime protocol. + /// + /// private sealed class FrameMessage : Message { private readonly RespRequest _request; + private readonly byte[]? _copy; internal FrameMessage(int database, in RespRequest request) - : base(database, request.Flags & ~Message.MaskRetryCategory | request.Flags, RedisCommand.UNKNOWN) + // the command's identity, not just its bytes: without it the pipeline cannot tell a write + // from a read, so IsPrimaryOnly lets a write be routed to a replica, and a profiler + // reports every command in the library as UNKNOWN + : base(database, request.Flags & ~Message.MaskRetryCategory | request.Flags, request.Command) { _request = request; + if ((request.Flags & CommandFlags.FireAndForget) != 0) + { + _copy = request.Span.ToArray(); + } } // an over-estimate is allowed, and the frame knows exactly @@ -73,7 +104,8 @@ internal FrameMessage(int database, in RespRequest request) // the slot was folded during the write, so routing needs no second look at the keys public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => _request.Slot; - protected override void WriteImpl(in MessageWriter writer) => writer.WriteRaw(_request.Span); + protected override void WriteImpl(in MessageWriter writer) + => writer.WriteRaw(_copy ?? _request.Span); } /// Captures the raw reply, undecoded, for the handler (or the cache) to read. From 32120ae663d37f6324fa37328f425204f97cee4f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:27:40 +0100 Subject: [PATCH 104/360] Pin the bytes, not just the behaviour The re-run of StringTests/BitTests checks semantics against real servers; these check the exact frames, against a fake executor, in the cases a server cannot distinguish: which operand order was used, whether a default rendered a token, which of several equivalent commands was chosen, and where the retry category landed. So: that SET..GET writes the condition before GET before the expiration, as the grammar documents and unlike the old builder's EX n XX GET; that BITCOUNT omits BYTE; that MSET is preferred over MSETEX whenever it can express the request; that INCRBY is always spelled with its argument; that a null value renders DEL rather than an empty string; that an all-GET BITFIELD says BITFIELD_RO; and that BITFIELD emits OVERFLOW only on a transition, with an intervening GET leaving the sticky mode alone. Fast, deterministic, and needs nothing running - a wire-format regression shows up here before it reaches a server. --- .../RespSurfaceStringsTests.cs | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs new file mode 100644 index 000000000..a3a06ff29 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs @@ -0,0 +1,510 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// What the string and bitmap groups put on the wire, byte for byte, against a fake executor. +/// +/// +/// +/// The integration half of the proof is TransitionalStringTests/TransitionalBitTests, which +/// re-run the existing suites through the new surface and so check semantics against real servers. +/// These check the other half: the exact bytes, including the cases a server would accept either way and +/// the ones a test against a server cannot distinguish - which argument order was used, whether a default +/// rendered a token, which of several equivalent commands was chosen. +/// +/// +/// Fast, deterministic, and needs nothing running; a wire-format regression shows up here first. +/// +/// +public class RespSurfaceStringsTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public List Flags { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + Flags.Add(request.Flags); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static (RespContext Context, FakeExecutor Executor) Target(params string[] replies) + { + var executor = new FakeExecutor(replies.Length == 0 ? ["+OK\r\n"] : replies); + return (new RespContext().WithExecutor(executor), executor); + } + + [Fact] + public async Task SimpleValueCommandsRenderTheirArgumentsInOrder() + { + var (ctx, exec) = Target(":8\r\n"); + + Assert.Equal(8, await ctx.Strings.Append("k", "defgh")); + Assert.Equal("*3|$6|APPEND|$1|k|$5|defgh|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task LengthAndRangeAreOrdinaryReads() + { + var (ctx, exec) = Target(":3\r\n", "$3\r\nabc\r\n"); + + await ctx.Strings.Length("k"); + await ctx.Strings.GetRange("k", 0, -1); + + Assert.Equal( + new[] { "*2|$6|STRLEN|$1|k|", "*4|$8|GETRANGE|$1|k|$1|0|$2|-1|" }, + exec.Sent); + + // both are pure reads, so both are cacheable by the flag gate + Assert.All(exec.Flags, f => Assert.Equal(CommandFlags.CommandRetryReadOnly, f & Message.MaskRetryCategory)); + } + + [Fact] + public async Task SetRangeRepliesWithALength() + { + var (ctx, exec) = Target(":11\r\n"); + + // long, not RedisValue: SETRANGE has only ever replied with an integer + Assert.Equal(11, await ctx.Strings.SetRange("k", 6, "Redis")); + Assert.Equal("*4|$8|SETRANGE|$1|k|$1|6|$5|Redis|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task IncrementIsAlwaysINCRBY() + { + var (ctx, exec) = Target(":1\r\n", ":-1\r\n", ":5\r\n"); + + await ctx.Strings.Increment("k"); + await ctx.Strings.Increment("k", -1); + await ctx.Strings.Increment("k", 5); + + // no INCR/DECR/DECRBY: the argument is always written, so there is no arity branch and no second + // spelling to keep in step. See the remarks on Increment. + Assert.Equal( + new[] + { + "*3|$6|INCRBY|$1|k|$1|1|", + "*3|$6|INCRBY|$1|k|$2|-1|", + "*3|$6|INCRBY|$1|k|$1|5|", + }, + exec.Sent); + } + + [Fact] + public async Task FloatingPointIncrementIsADifferentCommand() + { + var (ctx, exec) = Target("$3\r\n1.5\r\n"); + + // a value with an exact binary form, on purpose: a RedisValue renders a double round-trippably + // (G17), so 0.14 would go out as 0.14000000000000001 - which is what the old writer sends too, + // since it takes the same RedisValue conversion + Assert.Equal(1.5, await ctx.Strings.Increment("k", 1.5)); + Assert.Equal("*3|$11|INCRBYFLOAT|$1|k|$3|1.5|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task GetSetExpiryCoversEveryExpirationShape() + { + var (ctx, exec) = Target("$3\r\nabc\r\n"); + + await ctx.Strings.GetSetExpiry("k", default); // leave the TTL alone + await ctx.Strings.GetSetExpiry("k", TimeSpan.FromSeconds(300)); // EX + await ctx.Strings.GetSetExpiry("k", TimeSpan.FromMilliseconds(1500)); // PX + await ctx.Strings.GetSetExpiry("k", Expiration.Persist); // PERSIST + + Assert.Equal( + new[] + { + "*2|$5|GETEX|$1|k|", + "*4|$5|GETEX|$1|k|$2|EX|$3|300|", + "*4|$5|GETEX|$1|k|$2|PX|$4|1500|", + "*3|$5|GETEX|$1|k|$7|PERSIST|", + }, + exec.Sent); + + // a bare GETEX is the read the table says it is; anything that touches the TTL is a write + Assert.Equal(CommandFlags.CommandRetryReadOnly, exec.Flags[0] & Message.MaskRetryCategory); + Assert.All(exec.Flags.GetRange(1, 3), f => Assert.Equal(CommandFlags.CommandRetryWriteLastWins, f & Message.MaskRetryCategory)); + } + + [Fact] + public void GetSetExpiryRefusesAnExpirationItCannotSpell() + { + var (ctx, _) = Target(); + + // ENX has no place in GETEX's grammar; say so here rather than let the server say it later + Assert.Throws( + () => ctx.Strings.GetSetExpiry("k", new Expiration(TimeSpan.FromSeconds(30), ExpirationFlags.ExpireIfNotExists))); + } + + [Fact] + public async Task ManyKeysAreOneHoleAndStillKeys() + { + var (ctx, exec) = Target("*2\r\n$1\r\na\r\n$1\r\nb\r\n"); + + RedisKey[] keys = ["k1", "k2", "k3"]; + await ctx.WithKeyPrefix("t:").Strings.Get(keys); + + // every key in the run is prefixed, exactly as a single key is + Assert.Equal("*4|$4|MGET|$4|t:k1|$4|t:k2|$4|t:k3|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task NoKeysMeansNoCommand() + { + var (ctx, exec) = Target(); + + Assert.Empty(await ctx.Strings.Get(ReadOnlySpan.Empty)); + Assert.True(await ctx.Strings.Set(ReadOnlySpan>.Empty)); + + // an arity-zero MGET or MSET is a server error; "nothing" is answerable without asking + Assert.Empty(exec.Sent); + } + + [Fact] + public async Task MultiSetPicksTheWidelyAvailableCommandWhenItCan() + { + var (ctx, exec) = Target(); + KeyValuePair[] values = [new("a", "1"), new("b", "2")]; + + await ctx.Strings.Set(values); + await ctx.Strings.Set(values, when: ValueCondition.NotExists); + await ctx.Strings.Set(values, expiry: TimeSpan.FromSeconds(60)); + await ctx.Strings.Set(values, expiry: TimeSpan.FromSeconds(60), when: ValueCondition.Exists); + + Assert.Equal( + new[] + { + "*5|$4|MSET|$1|a|$1|1|$1|b|$1|2|", + "*5|$6|MSETNX|$1|a|$1|1|$1|b|$1|2|", + + // MSETEX takes a count first, then the pairs, then the tail + "*8|$6|MSETEX|$1|2|$1|a|$1|1|$1|b|$1|2|$2|EX|$2|60|", + "*9|$6|MSETEX|$1|2|$1|a|$1|1|$1|b|$1|2|$2|EX|$2|60|$2|XX|", + }, + exec.Sent); + } + + [Fact] + public async Task MultiSetRefusesAConditionWithNoMultiKeySpelling() + { + var (ctx, _) = Target(); + KeyValuePair[] values = [new("a", "1"), new("b", "2")]; + + await Assert.ThrowsAsync( + async () => await ctx.Strings.Set(values, when: ValueCondition.Equal("old"))); + } + + [Fact] + public async Task SetAndGetFollowsTheDocumentedOperandOrder() + { + var (ctx, exec) = Target("$3\r\nold\r\n"); + + await ctx.Strings.SetAndGet("k", "v", TimeSpan.FromSeconds(4), When.Exists); + + // condition, then GET, then expiration - the grammar as documented, not EX n XX GET + Assert.Equal("*7|$3|SET|$1|k|$1|v|$2|XX|$3|GET|$2|EX|$1|4|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task ANullValueRemovesTheKey() + { + var (ctx, exec) = Target(":1\r\n", "$3\r\nold\r\n"); + + // the long-standing meaning on this library's surface: there is no SET that stores "no value", + // and writing an empty string instead would be a different value, silently + await ctx.Strings.Set("k", RedisValue.Null); + await ctx.Strings.SetAndGet("k", RedisValue.Null); + + Assert.Equal(new[] { "*2|$3|DEL|$1|k|", "*2|$6|GETDEL|$1|k|" }, exec.Sent); + } + + [Fact] + public async Task DeleteChoosesDelOrDelexByCondition() + { + var (ctx, exec) = Target(":1\r\n"); + + await ctx.Strings.Delete("k"); + await ctx.Strings.Delete("k", ValueCondition.Exists); + await ctx.Strings.Delete("k", ValueCondition.Equal("old")); + + Assert.Equal( + new[] + { + "*2|$3|DEL|$1|k|", + "*2|$3|DEL|$1|k|", // "if it exists" is what DEL already means + "*4|$5|DELEX|$1|k|$4|IFEQ|$3|old|", + }, + exec.Sent); + } + + [Fact] + public async Task DeleteRefusesAConditionThatCannotBeAsked() + { + var (ctx, _) = Target(); + + // "delete it if it is absent" would quietly become "delete it" + await Assert.ThrowsAsync( + async () => await ctx.Strings.Delete("k", ValueCondition.NotExists)); + } + + [Fact] + public async Task BoundedIncrementRendersOnlyWhatWasAskedFor() + { + var (ctx, exec) = Target("*2\r\n:5\r\n:5\r\n"); + + await ctx.Strings.Increment("k", 5, TimeSpan.FromSeconds(60)); + await ctx.Strings.Increment("k", 5, TimeSpan.FromSeconds(60), lowerBound: 0, upperBound: 100, options: IncrementOptions.Saturate); + + Assert.Equal( + new[] + { + "*6|$6|INCREX|$1|k|$5|BYINT|$1|5|$2|EX|$2|60|", + "*11|$6|INCREX|$1|k|$5|BYINT|$1|5|$6|LBOUND|$1|0|$6|UBOUND|$3|100|$8|SATURATE|$2|EX|$2|60|", + }, + exec.Sent); + } + + [Fact] + public async Task BoundedIncrementReportsWhatWasActuallyApplied() + { + var (ctx, _) = Target("*2\r\n:100\r\n:40\r\n"); + + // under a bound the applied increment is not the one that was asked for; that is the whole reason + // INCREX has a two-element reply and a result type of its own + var result = await ctx.Strings.Increment("k", 60, TimeSpan.FromSeconds(60), upperBound: 100, options: IncrementOptions.Saturate); + Assert.Equal(100, result.Value); + Assert.Equal(40, result.AppliedIncrement); + } + + [Fact] + public void BoundedIncrementRefusesAnExpirationItCannotSpell() + { + var (ctx, _) = Target(); + + Assert.Throws(() => ctx.Strings.Increment("k", 1, Expiration.KeepTtl)); + Assert.Throws(() => ctx.Strings.Increment("k", 1, Expiration.Persist)); + } + + [Fact] + public async Task LongestCommonSubsequenceHasThreeShapes() + { + var (ctx, exec) = Target("$2\r\nab\r\n", ":2\r\n"); + + await ctx.Strings.LongestCommonSubsequence("a", "b"); + await ctx.Strings.LongestCommonSubsequenceLength("a", "b"); + + Assert.Equal( + new[] + { + "*3|$3|LCS|$1|a|$1|b|", + "*4|$3|LCS|$1|a|$1|b|$3|LEN|", + }, + exec.Sent); + } + + [Fact] + public async Task LongestCommonSubsequenceWithMatchesReadsTheIdxReply() + { + // ["matches", [[[4,7],[5,8],4]], "len", 6] + const string Reply = + "*4\r\n$7\r\nmatches\r\n*1\r\n*3\r\n*2\r\n:4\r\n:7\r\n*2\r\n:5\r\n:8\r\n:4\r\n$3\r\nlen\r\n:6\r\n"; + var (ctx, exec) = Target(Reply); + + var result = await ctx.Strings.LongestCommonSubsequenceWithMatches("a", "b", minLength: 4); + + Assert.Equal("*7|$3|LCS|$1|a|$1|b|$3|IDX|$11|MINMATCHLEN|$1|4|$12|WITHMATCHLEN|", Assert.Single(exec.Sent)); + Assert.Equal(6, result.LongestMatchLength); + var match = Assert.Single(result.Matches); + Assert.Equal(4, match.First.Start); + Assert.Equal(7, match.First.End); + Assert.Equal(4, match.Length); + } + + [Fact] + public async Task DigestComesBackAsAConditionAWriteCanUse() + { + var (ctx, exec) = Target("$16\r\n0123456789abcdef\r\n"); + + var digest = await ctx.Strings.Digest("k"); + Assert.Equal("*2|$6|DIGEST|$1|k|", Assert.Single(exec.Sent)); + + // the point of returning a ValueCondition rather than bytes: it goes straight back into a write + Assert.NotNull(digest); + Assert.True(digest.GetValueOrDefault().IsDigestTest); + } + + [Fact] + public async Task AMissingKeyHasNoDigest() + { + var (ctx, _) = Target("$-1\r\n"); + Assert.Null(await ctx.Strings.Digest("k")); + } + + [Fact] + public async Task BitCountAndPositionOmitTheDefaultIndexType() + { + var (ctx, exec) = Target(":2\r\n"); + + await ctx.Bitmaps.Count("k"); + await ctx.Bitmaps.Count("k", 0, 4, StringIndexType.Bit); + await ctx.Bitmaps.Position("k", true, 0, 4, StringIndexType.Bit); + await ctx.Bitmaps.Position("k", true, 2, StringIndex.Unbounded); + + Assert.Equal( + new[] + { + "*4|$8|BITCOUNT|$1|k|$1|0|$2|-1|", // BYTE is the server's own default: no token + "*5|$8|BITCOUNT|$1|k|$1|0|$1|4|$3|BIT|", + "*6|$6|BITPOS|$1|k|$1|1|$1|0|$1|4|$3|BIT|", + "*4|$6|BITPOS|$1|k|$1|1|$1|2|", // open-ended: no end, so no index type either + }, + exec.Sent); + } + + [Fact] + public void AnOpenEndedBitPositionCannotAlsoBeABitIndex() + { + var (ctx, _) = Target(); + + // there is nowhere to put the token, and dropping it would reinterpret `start` as a byte offset + Assert.Throws( + () => ctx.Bitmaps.Position("k", false, 2, StringIndex.Unbounded, StringIndexType.Bit)); + } + + [Fact] + public async Task BitOperationTakesAnyNumberOfSourceKeys() + { + var (ctx, exec) = Target(":1\r\n"); + + RedisKey[] sources = ["x", "y1", "y2"]; + await ctx.Bitmaps.Operation(Bitwise.Diff1, "dest", sources); + await ctx.Bitmaps.Operation(Bitwise.Not, "dest", ["x"]); + + Assert.Equal( + new[] + { + "*6|$5|BITOP|$5|DIFF1|$4|dest|$1|x|$2|y1|$2|y2|", + "*4|$5|BITOP|$3|NOT|$4|dest|$1|x|", + }, + exec.Sent); + } + + [Fact] + public void BitOperationChecksItsArityBeforeTheServerDoes() + { + var (ctx, _) = Target(); + + Assert.Throws(() => ctx.Bitmaps.Operation(Bitwise.And, "dest", ReadOnlySpan.Empty)); + Assert.Throws(() => ctx.Bitmaps.Operation(Bitwise.Not, "dest", ["a", "b"])); + } + + [Fact] + public async Task GetBitAndSetBitReadIntegerBooleans() + { + var (ctx, exec) = Target(":1\r\n", ":0\r\n"); + + Assert.True(await ctx.Bitmaps.Get("k", 10)); + Assert.False(await ctx.Bitmaps.Set("k", 10, true)); + + Assert.Equal( + new[] { "*3|$6|GETBIT|$1|k|$2|10|", "*4|$6|SETBIT|$1|k|$2|10|$1|1|" }, + exec.Sent); + } + + [Fact] + public async Task BitFieldEmitsTheStickyOverflowOnlyWhenItChanges() + { + var (ctx, exec) = Target("*5\r\n:0\r\n:127\r\n:127\r\n_\r\n:-29\r\n"); + + BitFieldOperation[] operations = + [ + BitFieldOperation.Set(BitFieldEncoding.Int8, 0, 100), + BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Saturate), + BitFieldOperation.Get(BitFieldEncoding.Int8, 0), + BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Fail), + ]; + + using var lease = await ctx.Bitmaps.Field("k", operations); + + // WRAP is in force to begin with, so the first SET emits no OVERFLOW; the GET does not disturb the + // sticky state, which is why the FAIL after it is the second and last transition + Assert.Equal( + "*21|$8|BITFIELD|$1|k|" + + "$3|SET|$2|i8|$1|0|$3|100|" + + "$8|OVERFLOW|$3|SAT|$6|INCRBY|$2|i8|$1|0|$3|100|" + + "$3|GET|$2|i8|$1|0|" + + "$8|OVERFLOW|$4|FAIL|$6|INCRBY|$2|i8|$1|0|$3|100|", + Assert.Single(exec.Sent)); + + Assert.Equal(new long?[] { 0, 127, 127, null, -29 }, lease.Span.ToArray()); + } + + [Fact] + public async Task AnAllGetBitFieldGoesOutAsTheReadOnlyCommand() + { + var (ctx, exec) = Target("*1\r\n:7\r\n"); + + Assert.Equal(7, await ctx.Bitmaps.Field("k", BitFieldOperation.Get(BitFieldEncoding.UInt8, 0))); + + // BITFIELD is a write to the server however read-only its sub-operations are, so an all-GET + // payload has to say BITFIELD_RO or a replica will refuse it + Assert.Equal("*5|$11|BITFIELD_RO|$1|k|$3|GET|$2|u8|$1|0|", Assert.Single(exec.Sent)); + Assert.Equal(CommandFlags.CommandRetryReadOnly, Assert.Single(exec.Flags) & Message.MaskRetryCategory); + } + + [Fact] + public async Task AnyWriteInABitFieldKeepsTheWritableCommand() + { + var (ctx, exec) = Target("*1\r\n:0\r\n"); + + await ctx.Bitmaps.Field("k", BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1)); + + Assert.StartsWith("*6|$8|BITFIELD|", Assert.Single(exec.Sent)); + + // SET is positional, so a replay lands on the same value; only INCRBY compounds + Assert.Equal(CommandFlags.CommandRetryWriteLastWins, Assert.Single(exec.Flags) & Message.MaskRetryCategory); + } + + [Fact] + public async Task AnElementOffsetUsesTheHashForm() + { + var (ctx, exec) = Target("*1\r\n:0\r\n"); + + await ctx.Bitmaps.Field("k", BitFieldOperation.Get(BitFieldEncoding.UInt8, BitFieldOffset.Element(2))); + + Assert.Equal("*5|$11|BITFIELD_RO|$1|k|$3|GET|$2|u8|$2|#2|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task TheFrameCarriesTheCommandsIdentityAsWellAsItsBytes() + { + var (ctx, _) = Target("$3\r\nabc\r\n"); + + // not decoration: the pipeline decides primary-vs-replica routing from Message.Command, and a + // profiler reports it. A frame that only knew its bytes reported every command as UNKNOWN. + using var frame = ctx.Execute($"{RedisCommand.GETRANGE}{(RedisKey)"k"}{(RedisValue)0}{(RedisValue)(-1)}"); + Assert.Equal(RedisCommand.GETRANGE, frame.Command); + + await Task.CompletedTask; + } +} From 4b4a74349b0a4bcbb9c1a4443065f10a46cc1953 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:42:47 +0100 Subject: [PATCH 105/360] Execute returns RespResult: the undecoded reply as a result type ctx.SendAsync($"...") now works, which is what lets another library's commands reach this surface at all - NRedisStack goes through the escape hatch and wants the reply, not a shape we happen to model. One handler registration lights up every such command without enumerating any of them. Mechanism, not permission: whether a reply may be CACHED is still per-command, and the server does not track FT.* for invalidation at all. It copies, and 6.16 records why that is the signature's fault rather than the implementation's. ReadLease already shares when it can: if (reader.TryReservePayload(out var reservation)) return Lease.Create(reservation.Owner, ...); // otherwise rent and copy ...but the first branch needs the reader to know which buffer the bytes live in, which is why RespResult.Read passes the buffer as a reader service. A ReadOnlySpan cannot carry that, so EVERY handler on this surface copies regardless of what it returns. Visible today in the Lease handler, which builds a reader with no service and so always takes the copy branch, where the connection path shares. So Parse(ref RespReader) is not just about composability: with the buffer attached it unlocks composable handlers, ReadLease sharing, and a RespResult that reserves rather than captures. With the caveat that sharing is safe per TOPOLOGY, not per type. A reply is single-owner, so a mutable lease into it is fine - that is why the connection path does it. A cache entry is multi-owner, and the same move lets one caller mutate bytes another will read. The reader service is exactly that switch. RespResult is the exception that proves it: read-only surface, safe in both topologies, and it holds the RAW FRAME with interpretation deferred - which also makes it immune to the streaming case that makes a byte-lease conditional. A chunked scalar must be assembled to be handed over as bytes; it does not have to be assembled to be stored. --- design/interpolated-resp-writer.md | 55 ++++++++ .../Interpolated/RespSurface.cs | 31 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/RespResult.cs | 22 ++++ .../RespResultHandlerTests.cs | 117 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index aedcdd63f..56c6354ae 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2247,6 +2247,61 @@ in §6.14. Growing `RespContext` field-by-field past its 48 bytes for those is t single small `CacheOptions` in the service slot, which is the pattern `ChannelPrefix` already set (§3.3). +### 6.16 `RespResult`, and why a span cannot share a buffer + +`RespResult` is registered as a built-in result type, so any command can come back undecoded: +`ctx.SendAsync($"...")`. That matters most for **other people's** commands — a library like +NRedisStack reaches the server through the escape hatch and wants the reply, not a shape this library +happens to model. Registering one handler lights the whole surface up without enumerating a single command. + +Keep two things apart: this is the *mechanism*, not permission. Whether a given reply may be **cached** is +still per-command, and the server does not track the `FT.*` family for invalidation at all. + +#### The finding: `Parse(ReadOnlySpan)` structurally cannot share + +`ReadLease` already implements the sharing we want, and picks between two strategies: + +```csharp +if (reader.TryReservePayload(out var reservation)) // contiguous, and the buffer is known + return Lease.Create(reservation.Owner, reservation.Offset, reservation.Length); +// otherwise rent and copy +``` + +The first branch needs the reader to know **which buffer the bytes live in** — which is why +`RespResult.Read()` constructs `new RespReader(buffer.GetSpan(), buffer)`, passing the buffer as a reader +*service*. + +A `ReadOnlySpan` cannot carry that. So every handler on the interpolated surface copies, whatever it +returns, and this is not a quality-of-implementation problem — it is the signature. It shows up concretely +in the `Lease` handler, which builds `new RespReader(response)` with no service and therefore always +takes the copy branch, where the connection path shares. + +**So `Parse(ref RespReader)` is not merely about composability.** If the executor constructs the reader with +the buffer attached, one change unlocks three things: composable handlers (§2.2's argument), `ReadLease` +sharing instead of copying, and a `RespResult` that reserves rather than captures. + +#### ...but sharing is safe per *topology*, not per *type* + +The reason the connection path can hand out a **mutable** `Lease` into its own reply buffer is that a +reply is **single-owner**: whoever received it owns it, and a lease into it is transitively theirs. The +existing comment even accepts the consequence — a small payload pins the whole reply, "deliberate, and +cheaper than the copy". + +A **cache entry is multi-owner**. The same move there lets one caller mutate bytes another will read, and +`Lease.ArraySegment` hands out the underlying pooled array, so it is not even bounded by the entry. + +So the reader's buffer service is precisely the switch: attach it for a fresh reply, withhold it for a +cache hit. Per-type rules do not work, because the *same* type is safe in one topology and not the other. + +`RespResult` is the exception that proves it: its public surface is read-only readers, so it is safe in +**both** topologies, and it holds the **raw frame** with interpretation deferred to `RespReader` — which +also makes it immune to the streaming case that makes a byte-lease conditional (a chunked scalar has to be +assembled to be handed over as bytes; it does not have to be assembled to be *stored*). + +A read-only byte lease is parked rather than rejected. `ReadLease` is the template if it comes back, and the +rule is "share when contiguous **and** single-owner; copy otherwise". + + ## 7. Analyzer rules The analyzer **does** reach consumers: `StackExchange.Redis.csproj:83-100` packs both diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 0683daea8..3c85638b3 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -69,6 +69,9 @@ public static class RespHandlers /// public static IRespHandler?> Lease { get; } = new LeaseHandler(); + /// The whole reply, undecoded - the general-purpose answer for commands we do not model. + public static IRespHandler Result { get; } = new RespResultHandler(); + /// Checks the reply for a server error, and reads nothing else. /// /// What a command with no result still has to do. Without it a failed command would complete @@ -104,6 +107,7 @@ internal static IRespHandler Require() else if (typeof(T) == typeof(RedisValue[])) handler = Values; else if (typeof(T) == typeof(string)) handler = String; else if (typeof(T) == typeof(Lease)) handler = Lease; + else if (typeof(T) == typeof(RespResult)) handler = Result; // Below this line: shapes that belong to ONE command. They are registered so a command // body stays one expression, but they are not exposed as named properties - a handler @@ -209,6 +213,33 @@ private sealed class StringHandler : IRespHandler } } + /// + /// Captures the whole reply, undecoded, as a . + /// + /// + /// + /// The general-purpose answer, and the one that matters most for other people's commands: a + /// library like NRedisStack reaches the server through the escape hatch and wants the reply, not a + /// decoded shape this library happens to know. Registering it here means every such command gets + /// the new surface without anyone enumerating commands - the plumbing lights up once. + /// + /// + /// Note it is not the same as "cacheable": whether a given command's reply may be cached is + /// still per-command, and the server does not track the FT.* family for invalidation at all. + /// What generalises is the mechanism. + /// + /// + /// This copies today, and that is not yet avoidable here. Sharing the buffer needs the reader + /// to know which buffer the bytes live in - RespResult.Read passes it as a reader service for + /// exactly that reason - and a parameter cannot carry it. See design + /// notes 6.16. + /// + /// + private sealed class RespResultHandler : IRespHandler + { + public RespResult Parse(ReadOnlySpan response) => RespResult.Capture(response); + } + private sealed class LeaseHandler : IRespHandler?> { public Lease? Parse(ReadOnlySpan response) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f5aefff0e..6950dd83b 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -226,3 +226,4 @@ static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackEx [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool [SER010]StackExchange.Redis.Interpolated.RespContext.WithMaxCacheAge(System.TimeSpan maxAge) -> StackExchange.Redis.Interpolated.RespContext [SER010]static StackExchange.Redis.Interpolated.CachePolicy.Default.get -> StackExchange.Redis.Interpolated.CachePolicy! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Result.get -> StackExchange.Redis.Interpolated.IRespHandler! diff --git a/src/StackExchange.Redis/RespResult.cs b/src/StackExchange.Redis/RespResult.cs index 2520791b4..3b178dc8f 100644 --- a/src/StackExchange.Redis/RespResult.cs +++ b/src/StackExchange.Redis/RespResult.cs @@ -48,6 +48,28 @@ private RespResult(RespPrefix prefix, bool isNull, RefCountedBuffer buffer) _buffer = buffer; } + /// + /// Capture a complete, already-framed reply from a span. + /// + /// The raw reply, header bytes included. + /// The pool to rent the copy from. + /// + /// The entry point for the interpolated surface, whose replies arrive as a finished frame rather than + /// through a connection's reader. It copies, exactly as the connection path does - and for the + /// interpolated path that copy is not yet avoidable: sharing needs the reader to know which buffer the + /// bytes live in, and a does not carry that. See design notes 6.16. + /// + internal static RespResult Capture(ReadOnlySpan frame, MemoryPool? pool = null) + { + var probe = new RespReader(frame); + probe.MovePastBof(); + + var buffer = RefCountedBuffer.Rent(frame.Length, pool); + var result = new RespResult(probe.Prefix, probe.IsNull, buffer); + frame.CopyTo(result.RawSpan); + return result; + } + internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReader reader, int length, MemoryPool? pool) { if (isNull) diff --git a/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs b/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs new file mode 100644 index 000000000..8762a5e13 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Messages; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// as a built-in result type: the undecoded reply, for commands this library does +/// not model - which is how another library's commands reach the new surface at all. +/// +public class RespResultHandlerTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + internal int Sends { get; private set; } + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sends++; + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static RespContext Context(FakeExecutor executor, RespClientCache? cache = null) + => new RespContext().WithExecutor(executor).WithCache(cache); + + [Fact] + public async Task AnyCommandCanComeBackAsARespResult() + { + var executor = new FakeExecutor("$5\r\nhello\r\n"); + using var result = await Context(executor).SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.None); + + Assert.Equal(RespPrefix.BulkString, result.Prefix); + Assert.False(result.IsNull); + + var reader = result.ReadScalar(); + Assert.Equal("hello", reader.ReadString()); + } + + [Fact] + public async Task AggregatesSurviveUndecoded() + { + // the point of the undecoded reply: we do not need to know the shape. This is what a module command + // looks like to us. + var executor = new FakeExecutor("*3\r\n$1\r\na\r\n:42\r\n$-1\r\n"); + using var result = await Context(executor).SendAsync($"{RedisCommand.MGET}{(RedisKey)"k"}", CommandFlags.None); + + Assert.Equal(RespPrefix.Array, result.Prefix); + + var reader = result.Read(); + Assert.Equal(3, reader.AggregateLength()); + Assert.True(reader.TryMoveNext(false)); + Assert.Equal("a", reader.ReadString()); + Assert.True(reader.TryMoveNext(false)); + Assert.Equal(42, reader.ReadInt64()); + Assert.True(reader.TryMoveNext(false)); + Assert.True(reader.IsNull); + } + + [Fact] + public async Task ANullReplyIsARespResultToo() + { + var executor = new FakeExecutor("_\r\n"); + using var result = await Context(executor).SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.None); + + Assert.True(result.IsNull); + } + + [Fact] + public async Task ARespResultServedFromCacheIsIndistinguishable() + { + // whether the bytes came from the wire or the cache must not be observable except in timing + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var context = Context(executor, cache); + + using (var first = await context.SendAsync( + $"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly)) + { + Assert.Equal("hello", first.ReadScalar().ReadString()); + } + + using (var second = await context.SendAsync( + $"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly)) + { + Assert.Equal("hello", second.ReadScalar().ReadString()); + } + + Assert.Equal(1, executor.Sends); // the second was a cache hit + } + + [Fact] + public async Task TheResultOutlivesTheReplyItCameFrom() + { + // the pipeline releases its own reference as soon as Parse returns, so a result that did not own + // its bytes would be reading a recycled buffer by the time the caller looked + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var result = await Context(executor).SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.None); + + for (var i = 0; i < 32; i++) _ = new byte[4096]; // churn the pool a little + GC.Collect(); + + Assert.Equal("hello", result.ReadScalar().ReadString()); + result.Dispose(); + } +} From 97ebf69df0dcf519587b0a6cf69cb4a26154772f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:45:50 +0100 Subject: [PATCH 106/360] Decide: share internally, copy on the way out The interpolated surface always copies what it hands to a caller. Recorded in 6.16, with the reason that is easiest to miss: sharing PINS. ReadLease's own comment accepts that a small payload can pin the whole reply as a deliberate trade - fine for a reply about to be dropped, much worse with a cache, where entries live for minutes and a caller keeping one field of a large multi-key reply holds the whole buffer for the life of the entry. Copying a small value out is not only safer, it is smaller. The economics were never compelling either: the cache's win is skipping a round trip, not a memcpy of a typically-tiny reply. It dissolves everything we were about to build - no read-only reservation interface, no "is this shared" flag on RespReader, no retiring ReadLease by removing its `this`, and so no source break on a shipped API. And it makes Lease correct again: it was only ever wrong when shared, so if nothing outgoing shares, a mutable lease is the honest type and ReadOnlyLease stops being needed. Scoped to the new surface. Sharing on the existing ResultProcessor path is correct - single-owner - and making it copy too is a bigger decision than it looks: ReadLease is the ONLY consumer of TryReservePayload, so it would strand IPayloadReservationProvider, TryReserve and PayloadReservation as dead code, and RespResult's refcounting - which exists precisely so leases can point into it - would never exceed one. That deserves its own merits, not a side effect. No code change: the new surface already complies. --- design/interpolated-resp-writer.md | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 56c6354ae..2e5656972 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2298,8 +2298,40 @@ cache hit. Per-type rules do not work, because the *same* type is safe in one to also makes it immune to the streaming case that makes a byte-lease conditional (a chunked scalar has to be assembled to be handed over as bytes; it does not have to be assembled to be *stored*). -A read-only byte lease is parked rather than rejected. `ReadLease` is the template if it comes back, and the -rule is "share when contiguous **and** single-owner; copy otherwise". +#### Decided: share internally, copy on the way out + +The interpolated surface **always copies what it hands to a caller**. Not a retreat — three reasons, and the +first is the one that is easy to miss: + +- **Sharing pins.** `ReadLease`'s own comment accepts that "a small payload can pin the whole reply - + deliberate, and cheaper than the copy". That trade is fine for a reply about to be dropped. With a cache + it is much worse: entries live for *minutes*, so a caller keeping one field of a large multi-key reply + holds the entire buffer for the life of the entry. Copying a small value out is not only safer, it is + **smaller**. +- **The economics were never there.** The cache's win is skipping a network round trip, not skipping a + memcpy of a typically-tiny reply. +- **It dissolves the whole topology problem.** No `IReadOnlyPayloadReservationProvider`, no "is this buffer + shared" flag on `RespReader`, no retiring `ReadLease` by removing its `this`, and therefore no source break + on a shipped API. + +And a pleasing consequence: **always-copy makes `Lease` correct again**. It was only ever wrong when +shared; if nothing outgoing shares, the caller genuinely owns those bytes and a mutable lease is the honest +type. `ReadOnlyLease` stops being needed for safety — and arguably at all. + +The rule, in one line: **share internally, copy on the way out.** The cache still shares the reply payload +with itself (`TryComplete` retains rather than copies, §6.3); what it never does is hand a caller a view of +something anybody else can reach. + +**Scope: the new surface only.** Sharing on the existing `ResultProcessor` path is *correct* — it is +single-owner — and the pinning cost there was a deliberate trade by whoever wrote it. Making that copy too +is a separate decision, and a larger one than it looks: `ReadLease` is the **only** consumer of +`TryReservePayload`, so it would leave `IPayloadReservationProvider`, `TryReserve` and `PayloadReservation` +as dead code, and `RespResult`'s reference counting — which exists precisely so leases can point into it — +would never exceed one. Worth doing on its own merits or not at all, rather than as a side effect of this. + +A read-only byte lease is therefore parked, and would only come back if the outgoing-copy rule were +revisited. `ReadLease` is the template if so, and the rule would be "share when contiguous **and** +single-owner; copy otherwise". ## 7. Analyzer rules From ef2031ae5b21f0929f51c495c95547fec6fa0ed4 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:46:34 +0100 Subject: [PATCH 107/360] Correct 6.16: "always copy" is about the MUTABLE outgoing type I collapsed two types into one rule and concluded ReadOnlyLease was unnecessary. That throws away the only reason to have it. The design is two types with two different reasons to be safe: Lease always copies caller owns bytes nobody else reaches ReadOnlyLease shares when it can nobody can write through it Chasing it properly, my own pinning argument flips by topology, and in favour of sharing where it matters. Sharing a FRESH REPLY pins a buffer that would otherwise be recycled at once - that is the cost ReadLease's comment is about. Sharing a CACHE ENTRY pins nothing extra: the cache holds that buffer for the entry's lifetime regardless. So a read-only lease over a cache hit is safe AND free, which is precisely the case this whole exercise is about. The machinery collapses too. No "is this shared" flag on RespReader and no second reservation interface: the mutable path simply never calls TryReservePayload, the read-only path does, and the TYPE decides at compile time with no topology reasoning at any call site. TryReservePayload and IPayloadReservationProvider stay alive serving the read-only path, so the dead-code cascade I worried about does not happen either. Rule restated: share what cannot be written; copy what can. --- design/interpolated-resp-writer.md | 55 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 2e5656972..e98e0b370 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2314,24 +2314,43 @@ first is the one that is easy to miss: shared" flag on `RespReader`, no retiring `ReadLease` by removing its `this`, and therefore no source break on a shipped API. -And a pleasing consequence: **always-copy makes `Lease` correct again**. It was only ever wrong when -shared; if nothing outgoing shares, the caller genuinely owns those bytes and a mutable lease is the honest -type. `ReadOnlyLease` stops being needed for safety — and arguably at all. - -The rule, in one line: **share internally, copy on the way out.** The cache still shares the reply payload -with itself (`TryComplete` retains rather than copies, §6.3); what it never does is hand a caller a view of -something anybody else can reach. - -**Scope: the new surface only.** Sharing on the existing `ResultProcessor` path is *correct* — it is -single-owner — and the pinning cost there was a deliberate trade by whoever wrote it. Making that copy too -is a separate decision, and a larger one than it looks: `ReadLease` is the **only** consumer of -`TryReservePayload`, so it would leave `IPayloadReservationProvider`, `TryReserve` and `PayloadReservation` -as dead code, and `RespResult`'s reference counting — which exists precisely so leases can point into it — -would never exceed one. Worth doing on its own merits or not at all, rather than as a side effect of this. - -A read-only byte lease is therefore parked, and would only come back if the outgoing-copy rule were -revisited. `ReadLease` is the template if so, and the rule would be "share when contiguous **and** -single-owner; copy otherwise". +**"Always copy" applies to the *mutable* outgoing type, not to every outgoing type.** Two types, two +different reasons to be safe: + +| outgoing type | strategy | safe because | +|---|---|---| +| `Lease` | always copies | the caller owns bytes nobody else can reach | +| `ReadOnlyLease` | shares when it can | nobody can write through it | + +So `Lease` becomes correct again — it was only ever wrong when shared — **and** `ReadOnlyLease` +earns its existence by being the one that may share. Collapsing both into "copy" throws away the only +reason to have the second type. + +**The pinning argument flips by topology, and in favour of sharing where it matters.** Sharing a *fresh +reply* pins a buffer that would otherwise be recycled at once — a real cost, and the one `ReadLease`'s +comment is about. Sharing a *cache entry* pins **nothing extra**: the cache is holding that buffer for the +entry's lifetime whatever the caller does. So a read-only lease over a cache hit is safe *and* free, which +is the case the whole exercise is about. + +**And the machinery collapses.** No "is this buffer shared" flag on `RespReader`, no second reservation +interface: the mutable path simply **never calls `TryReservePayload`**, and the read-only path does. The +*type* decides, at compile time, with no topology reasoning at any call site. `TryReservePayload` and +`IPayloadReservationProvider` stay alive serving the read-only path, so there is no dead-code cascade +either. + +Streaming behaves as it does today: a read-only lease shares when the payload is one contiguous run and +copies when it is chunked — exactly the two branches `ReadLease` already has, and exactly what `Lease`'s +dual backing (`T[]` *or* `IMemoryOwner`) exists to express. + +The rule, in one line: **share what cannot be written; copy what can.** The cache also shares the reply +payload with itself (`TryComplete` retains rather than copies, §6.3). + +**What this means for the existing `ReadLease`.** It is the mutable one, and today it shares — which is +correct where it is used (single-owner) but is the wrong default under the rule above. Retiring that +spelling in favour of a read-only one is the plan sketched in §6.17: the old method keeps its symbol for +binary compatibility and loses its `this`, while a read-only `ReadLease` takes over the call site. The +return-type change is a **source** break on a shipped API — caught at compile time, and a rebuild on upgrade +picks up the copy-safe version. ## 7. Analyzer rules From 9a1a37bfd156b736e9c4f0e60f186c25ce86bacc Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:52:56 +0100 Subject: [PATCH 108/360] ReadOnlyLease: the lease that may share Two types, two reasons to be safe. Lease is safe because the caller owns bytes nobody else reaches; ReadOnlyLease is safe because nobody can write through it - so it can point into memory that is still owned elsewhere, which is the whole point for a cache entry. RespReaderExtensions.ReadLease keeps its name, signature and containing type - so anything already compiled against it still binds - and merely stops being an extension method, with [Obsolete] saying where to go. It now always copies. RespReaderLeaseExtensions.ReadLease takes over the call site and shares where it can. Source break for callers who named the type or wrote through the result, which is exactly the set for whom sharing would have been unsafe; recorded as an explicit *REMOVED* line so it shows in the API diff rather than being discovered. No ArraySegment on the read-only type: Lease has one and it hands out the underlying array, which for shared memory reaches outside the lease. The DecodeString/AsStream overloads get an array internally via MemoryMarshal.TryGetArray - the library may do what it does not offer callers by default, and anyone reaching for MemoryMarshal themselves has accepted the responsibility that comes with it. A class, not a struct: RefCountedBuffer.Release() is a bare decrement with no idempotence guard, so a copied-and-disposed-twice struct would hand a live buffer back to its pool while other holders still read from it. Dispose is exchange-to-null, so repeated disposal is a no-op - tested. Also adds design/interpolated-resp-writer.queue.md: the working queue, kept out of the design notes so it can be edited without conflicting with them. --- design/interpolated-resp-writer.md | 29 +++- design/interpolated-resp-writer.queue.md | 88 +++++++++++ src/StackExchange.Redis/ExtensionMethods.cs | 49 +++++++ .../Interpolated/RespSurface.cs | 22 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 15 ++ src/StackExchange.Redis/ReadOnlyLease.cs | 137 ++++++++++++++++++ .../RespReaderExtensions.cs | 24 ++- .../RespReaderLeaseExtensions.cs | 81 +++++++++++ .../ResultProcessor.Lease.cs | 12 +- .../ReadOnlyLeaseTests.cs | 120 +++++++++++++++ .../RespResultLeaseSharingTests.cs | 18 +-- 11 files changed, 565 insertions(+), 30 deletions(-) create mode 100644 design/interpolated-resp-writer.queue.md create mode 100644 src/StackExchange.Redis/ReadOnlyLease.cs create mode 100644 src/StackExchange.Redis/RespReaderLeaseExtensions.cs create mode 100644 tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index e98e0b370..2ddb835d4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2345,12 +2345,29 @@ dual backing (`T[]` *or* `IMemoryOwner`) exists to express. The rule, in one line: **share what cannot be written; copy what can.** The cache also shares the reply payload with itself (`TryComplete` retains rather than copies, §6.3). -**What this means for the existing `ReadLease`.** It is the mutable one, and today it shares — which is -correct where it is used (single-owner) but is the wrong default under the rule above. Retiring that -spelling in favour of a read-only one is the plan sketched in §6.17: the old method keeps its symbol for -binary compatibility and loses its `this`, while a read-only `ReadLease` takes over the call site. The -return-type change is a **source** break on a shipped API — caught at compile time, and a rebuild on upgrade -picks up the copy-safe version. +#### Built: `ReadOnlyLease`, and retiring the mutable spelling + +`RespReaderExtensions.ReadLease` keeps its name, signature and containing type — so anything already +compiled against it still binds — and merely stops being an extension method, gaining `[Obsolete]` +explaining where to go. `RespReaderLeaseExtensions.ReadLease` takes over the call site, returning +`ReadOnlyLease` and sharing where it can. The old one now **always copies**. + +That is a **source** break for callers who named the type or wrote through the result — precisely the +callers for whom sharing would have been unsafe, which is why a compile error is the right way for them to +find out. It is recorded as an explicit `*REMOVED*` line in the public API files rather than left to be +discovered, so the break appears in the API diff. + +`ReadOnlyLease` deliberately has **no `ArraySegment`**. `Lease` has one and it hands out the +underlying array, which for shared memory is a way to reach outside the lease entirely. `DecodeString` and +`AsStream` still need an array, and get one internally via `MemoryMarshal.TryGetArray` — the library may do +what it will not offer callers by default. A caller determined to reach the array through `MemoryMarshal` +themselves can, and that is their responsibility: it is an explicit escape hatch, not an accident. + +Sharing **pins**, and that is the remaining cost: the lease holds the whole reply alive. +`ReadOnlyLease.ToArray()` is the way out when a small value must outlive a large reply. For a *cached* +reply the pinning is free, because the entry holds that buffer anyway. + +The working queue lives in `design/interpolated-resp-writer.queue.md`. ## 7. Analyzer rules diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md new file mode 100644 index 000000000..646ab870d --- /dev/null +++ b/design/interpolated-resp-writer.queue.md @@ -0,0 +1,88 @@ +# Queue — interpolated RESP writer / client-side caching + +Working list for the `marc/interpolated-writer-design` branch. Kept separate from +`interpolated-resp-writer.md` so it can be edited without conflicting with the design notes, which are +long and appended to constantly. + +**Convention:** items move to Done with the commit that closed them. Anything removed rather than done gets +a line saying why, because "we decided not to" is worth as much as "we did". + +--- + +## Now + +- [ ] **Stale-while-revalidate** (§6.15). Soft/hard thresholds on `CachePolicy`, once-only refresh via an + interlocked flag on the entry, clearing on failure with backoff. Prerequisites are in: single-flight + is the same interlock, and `CachePolicy` already carries the lifetime. Remember the cap for the + compounding-staleness case, and that invalidation-SWR must **not** apply to invalidations we caused + ourselves (read-your-own-writes). + +- [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, + `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in + `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and + small. `DUMP` wants a second opinion. + +- [ ] **Wire `OnFlush()` to disconnect.** It exists and nothing calls it. Comes from the same server + documentation that gave us the TTL backstop: *"if the connection is lost, the local cache is + flushed"*. Currently the cache would serve entries invalidated while we were not listening. + +## Next + +- [ ] **`Parse(ref RespReader)`** (§2.2, §6.16). Smaller prize than it looked once the outgoing-copy rule + landed — the sharing argument moved to `ReadOnlyLease` — so it is back to being about + **composability**: `IRespHandler` built from `IRespHandler`. Cheapest while handlers live in + one file. Mechanical: delete two lines per handler, take the parameter. + +- [ ] **Route invalidation pushes through `PhysicalConnection`** (§6.13). Two known changes, not guesses: + a `[AsciiHash("invalidate")] Invalidate` member on `PushKind`, and handling it **before** the + `TryMoveNextString` gate — that gate demands an inline string second element (the pub/sub channel), + whereas an invalidation's is an array or a null, so the enum member alone changes nothing. + `TrackingExecutor` in the tests is the known-good target to match. + +- [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only, `BCAST`, empty prefix by default + (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without + invalidation. + +- [ ] **More command groups**, in `RespSurface..cs` + `TransitionalDatabase..cs` pairs. + Mechanical now; `Strings` and `Bitmaps` are the worked examples. SER352 counts what is left. + +## Later / decide first + +- [ ] **`IServer` / `ISubscriber` contexts** still throw from `IRespTarget.Context`. + +- [ ] **The retry executor** (`WithRetry`). Prerequisites in place; no design written. + +- [ ] **Should the existing `ResultProcessor` path copy too?** (§6.16). Sharing there is *correct* — it is + single-owner — so this is a policy change, not a fix, and it would strand `TryReservePayload`, + `IPayloadReservationProvider` and `PayloadReservation` as dead code now that `ReadLease` (read-only) + is their only remaining consumer. Own merits or not at all. + +- [ ] **Module-read tracking.** Do module reads register for invalidation? A five-minute experiment + against a real server, never run. Relevant because the docs put the whole `FT.*` family outside + server-side tracking. + +- [ ] **`RespContext` sizing.** Currently 48 bytes. `CachePolicy` rides on the cache and the freshness + override rides in the service slot, so nothing has grown it yet — but SWR adds knobs, and the + measurement that justified moving `ChannelPrefix` out (§3.3) should be repeated rather than assumed. + +## Done + +- [x] Single-flight / request coalescing — `934e8d2d` +- [x] `CachePolicy` + finite entry lifetime, per-context `WithMaxCacheAge` — `9cf7da77` +- [x] Invalidation delivery proven against a real server (`TrackingExecutor`) — `4f02b657` +- [x] Push classification matching `PhysicalConnection` — `1f8e3cbd` +- [x] `RespResult` as a built-in result type (the NRedisStack path) — `4b4a7434` +- [x] `ReadOnlyLease`, and retiring the mutable `ReadLease` spelling — this change + +## Decided against + +- **A "buffer is shared" flag on `RespReader`**, and a second read-only reservation interface. Unnecessary + once the *type* carries the distinction: the mutable path simply never calls `TryReservePayload`. §6.16. +- **`ReadOnlyLease` as a `struct`.** `RefCountedBuffer.Release()` is a bare decrement with no + idempotence guard, so a copied-and-disposed-twice struct would return a live buffer to the pool while + other holders still read it. +- **`NOLOOP` on `CLIENT TRACKING`.** In default mode the server stops tracking a key we wrote even when it + suppresses the message, so anything whose key set we under-declare (`EVAL` with computed keys) goes + *permanently* stale rather than briefly. §6.13. +- **Deriving `BCAST PREFIX` from `WithKeyPrefix`.** Prefixes are connection-global, must not overlap — + context prefixes routinely nest — and cannot be removed individually. §6.13. diff --git a/src/StackExchange.Redis/ExtensionMethods.cs b/src/StackExchange.Redis/ExtensionMethods.cs index ac1a4ec87..0e302d762 100644 --- a/src/StackExchange.Redis/ExtensionMethods.cs +++ b/src/StackExchange.Redis/ExtensionMethods.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net.Security; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -230,6 +231,54 @@ internal static Task AuthenticateAsClientAsync(this SslStream ssl, string host, return new MemoryStream(segment.Array!, segment.Offset, segment.Count, false, true); } + /// + /// Exposes a read-only lease as a . + /// + /// The lease to read. + /// Whether the stream should dispose the lease when it is disposed. + /// + /// Reaches the backing array via rather than an + /// ArraySegment accessor: deliberately has none, because + /// handing out the array is a way to reach outside the lease - and for a shared buffer, into + /// somebody else's data. The library may do internally what it will not let callers do. + /// +#pragma warning disable RS0026 // overloads with optional args; they differ by receiver type, so not ambiguous + public static Stream? AsStream(this ReadOnlyLease? bytes, bool ownsLease = true) +#pragma warning restore RS0026 + { + if (bytes is null) return null; // GIGO + + if (!MemoryMarshal.TryGetArray(bytes.Memory, out var segment)) + { + // not array-backed; the copy is the only way to satisfy a Stream over it + var copy = bytes.ToArray(); + segment = new ArraySegment(copy, 0, copy.Length); + ownsLease = false; + } + + return ownsLease + ? new LeaseMemoryStream(segment, bytes) + : new MemoryStream(segment.Array!, segment.Offset, segment.Count, false, true); + } + + /// + /// Decodes a read-only lease as a . + /// + /// The lease to decode. + /// The encoding to use; UTF-8 when not specified. +#pragma warning disable RS0026 // as above + public static string? DecodeString(this ReadOnlyLease? bytes, Encoding? encoding = null) +#pragma warning restore RS0026 + { + if (bytes is null) return null; + if (bytes.Length == 0) return ""; + + encoding ??= Encoding.UTF8; + return MemoryMarshal.TryGetArray(bytes.Memory, out var segment) + ? encoding.GetString(segment.Array!, segment.Offset, segment.Count) + : encoding.GetString(bytes.ToArray()); + } + /// /// Decode a byte-Lease as a String, optionally specifying the encoding (UTF-8 if omitted). /// diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 3c85638b3..5164d1a04 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -69,6 +69,9 @@ public static class RespHandlers /// public static IRespHandler?> Lease { get; } = new LeaseHandler(); + /// The reply as a read-only buffer; shares the underlying memory where it can. + public static IRespHandler?> ReadOnlyLease { get; } = new ReadOnlyLeaseHandler(); + /// The whole reply, undecoded - the general-purpose answer for commands we do not model. public static IRespHandler Result { get; } = new RespResultHandler(); @@ -107,6 +110,7 @@ internal static IRespHandler Require() else if (typeof(T) == typeof(RedisValue[])) handler = Values; else if (typeof(T) == typeof(string)) handler = String; else if (typeof(T) == typeof(Lease)) handler = Lease; + else if (typeof(T) == typeof(ReadOnlyLease)) handler = ReadOnlyLease; else if (typeof(T) == typeof(RespResult)) handler = Result; // Below this line: shapes that belong to ONE command. They are registered so a command @@ -240,12 +244,30 @@ private sealed class RespResultHandler : IRespHandler public RespResult Parse(ReadOnlySpan response) => RespResult.Capture(response); } + /// The reply as a buffer the caller owns outright, and may write to. + /// + /// Copies, necessarily: a mutable lease must not point at memory anything else can read. The + /// sibling is the one that can share. See design notes 6.16. + /// private sealed class LeaseHandler : IRespHandler?> { public Lease? Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); +#pragma warning disable CS0618 // Type or member is obsolete - the copying form is what this contract needs + return RespReaderExtensions.ReadLease(in reader); +#pragma warning restore CS0618 + } + } + + /// The reply as a read-only buffer, which may share rather than copy. + private sealed class ReadOnlyLeaseHandler : IRespHandler?> + { + public ReadOnlyLease? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); return reader.ReadLease(); } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6950dd83b..f9b79e802 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -227,3 +227,18 @@ static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackEx [SER010]StackExchange.Redis.Interpolated.RespContext.WithMaxCacheAge(System.TimeSpan maxAge) -> StackExchange.Redis.Interpolated.RespContext [SER010]static StackExchange.Redis.Interpolated.CachePolicy.Default.get -> StackExchange.Redis.Interpolated.CachePolicy! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Result.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ReadOnlyLease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! +StackExchange.Redis.ReadOnlyLease +StackExchange.Redis.ReadOnlyLease.Dispose() -> void +StackExchange.Redis.ReadOnlyLease.IsEmpty.get -> bool +StackExchange.Redis.ReadOnlyLease.Length.get -> int +StackExchange.Redis.ReadOnlyLease.Memory.get -> System.ReadOnlyMemory +StackExchange.Redis.ReadOnlyLease.Span.get -> System.ReadOnlySpan +StackExchange.Redis.ReadOnlyLease.ToArray() -> T[]! +StackExchange.Redis.RespReaderLeaseExtensions +static StackExchange.Redis.ReadOnlyLease.Empty.get -> StackExchange.Redis.ReadOnlyLease! +static StackExchange.Redis.RespReaderExtensions.ReadLease(in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? +static StackExchange.Redis.RespReaderLeaseExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.ReadOnlyLease? +*REMOVED*static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? +static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.ReadOnlyLease? bytes, bool ownsLease = true) -> System.IO.Stream? +static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? diff --git a/src/StackExchange.Redis/ReadOnlyLease.cs b/src/StackExchange.Redis/ReadOnlyLease.cs new file mode 100644 index 000000000..7cf5b6a6c --- /dev/null +++ b/src/StackExchange.Redis/ReadOnlyLease.cs @@ -0,0 +1,137 @@ +using System; +using System.Buffers; +using System.Threading; + +namespace StackExchange.Redis +{ + /// + /// A sized region of contiguous read-only memory; disposing the lease releases it. + /// + /// The type of data being leased. + /// + /// + /// The read-only sibling of , and the difference is not cosmetic - it is what + /// allows the data to be shared rather than copied. A mutable lease over memory anyone else can + /// reach lets one holder rewrite what another is about to read; a read-only one cannot, so it is safe + /// to hand out a view of a buffer that is still owned elsewhere - a client-side cache entry, for + /// instance. + /// + /// + /// Hence the rule the two types express between them: share what cannot be written, copy what can. + /// A is safe because the caller owns bytes nobody else reaches; + /// a is safe because nobody can write through it. + /// + /// + /// Deliberately a class, not a struct. It owns a reference that must be released exactly once, + /// and the underlying release is a bare decrement with no idempotence guard - so a struct copied and + /// disposed twice would drive the count negative and hand a live buffer back to its pool while other + /// holders still read from it. + /// + /// + /// There is deliberately no ArraySegment accessor. has one, and it hands + /// out the underlying array - which for a shared buffer is a way to reach outside the lease entirely. + /// + /// + public sealed class ReadOnlyLease : IDisposable + { + /// + /// A lease of length zero. + /// + public static ReadOnlyLease Empty { get; } = new(System.Array.Empty(), 0, 0); + + // either a T[] rented from the shared pool (the copied case), or an IMemoryOwner whose memory + // this lease holds a reference to (the shared case). One type covers both because a payload that + // is not one contiguous run - a streamed scalar arriving in chunks - has to be assembled, and + // therefore copied, however much we would rather share it. + private object? _buffer; + + private readonly int _offset; + + /// Gets whether this lease is empty. + public bool IsEmpty => Length == 0; + + /// The length of the lease. + public int Length { get; } + + private ReadOnlyLease(object? buffer, int offset, int length) + { + _buffer = buffer; + _offset = offset; + Length = length; + } + + /// Create a lease over a rented array, for data that had to be copied. + /// The size required. + /// The pool to rent from; the shared array pool when null. + /// The memory to write the data into. + internal static ReadOnlyLease Rent(int length, MemoryPool? pool, out Span target) + { + if (length == 0) + { + target = default; + return Empty; + } + + if (pool is not null) + { + var owner = pool.Rent(length); + target = owner.Memory.Span.Slice(0, length); + return new ReadOnlyLease(owner, 0, length); + } + + var array = ArrayPool.Shared.Rent(length); + target = new Span(array, 0, length); + return new ReadOnlyLease(array, 0, length); + } + + /// + /// Create a lease that shares an existing buffer rather than copying out of it. + /// + /// The buffer to point into; a reference must already have been taken. + /// Where the data starts within the buffer. + /// How much of it belongs to this lease. + /// + /// The caller takes the reference; disposing this lease gives it back. Sharing is why this type + /// exists - see the remarks on the type. + /// + internal static ReadOnlyLease Share(IMemoryOwner owner, int offset, int length) + => length == 0 ? Empty : new ReadOnlyLease(owner, offset, length); + + /// The data as a . + public ReadOnlyMemory Memory => _buffer is IMemoryOwner owner + ? owner.Memory.Slice(_offset, Length) + : new ReadOnlyMemory((T[]?)_buffer ?? ThrowDisposed(), _offset, Length); + + /// The data as a . + public ReadOnlySpan Span => _buffer is IMemoryOwner owner + ? owner.Memory.Span.Slice(_offset, Length) + : new ReadOnlySpan((T[]?)_buffer ?? ThrowDisposed(), _offset, Length); + + /// Copy the contents into a new array. + /// For a caller who needs to own the data outright, or to outlive this lease. + public T[] ToArray() => Span.ToArray(); + + private static T[] ThrowDisposed() => throw new ObjectDisposedException(nameof(ReadOnlyLease)); + + /// Release the memory owned or referenced by this lease. + /// + /// Exchange-to-null makes this once-only however many times it is called, which matters because the + /// release underneath is not idempotent: a second one would decrement somebody else's reference. + /// + public void Dispose() + { + if (Length == 0) return; + + var buffer = Interlocked.Exchange(ref _buffer, null); + switch (buffer) + { + case T[] array: + ArrayPool.Shared.Return(array); + break; + case IMemoryOwner owner: + owner.Dispose(); + break; + } + } + } +} diff --git a/src/StackExchange.Redis/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index 1048d5e2c..b5a97af10 100644 --- a/src/StackExchange.Redis/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -79,7 +79,12 @@ public static RedisValue ReadRedisValue(this in RespReader reader) /// than retained as a lease. /// /// - public static Lease? ReadLease(this in RespReader reader) + [Obsolete( + "Use the ReadLease returning ReadOnlyLease (RespReaderLeaseExtensions); this one now always " + + "copies, because a mutable lease must not share memory anybody else can read. It is no longer an " + + "extension method, so 'reader.ReadLease()' resolves to the read-only form.", + error: false)] + public static Lease? ReadLease(in RespReader reader) { reader.DemandScalar(); if (reader.IsNull) return null; @@ -87,18 +92,11 @@ public static RedisValue ReadRedisValue(this in RespReader reader) var length = reader.ScalarLength(); if (length == 0) return Lease.Empty; - // if the payload is a single contiguous run inside a buffer that supports counted reservations, - // point at it rather than copying; the lease then keeps that buffer alive until it is disposed, - // which means a small payload can pin the whole reply - deliberate, and cheaper than the copy - if (reader.TryReservePayload(out var reservation)) - { - Debug.Assert(reservation.Length == length, "reserved length mismatch"); - return Lease.Create(reservation.Owner, reservation.Offset, reservation.Length); - } - - // otherwise copy - renting from the same pool the data came from, which the reader knows about - // via its services; there is deliberately no pool argument, because on the sharing path above - // any such argument would be silently ignored + // ALWAYS copies, and deliberately no longer reserves against the reader's buffer. A Lease is + // mutable - Span, Memory and ArraySegment are all writable, and ArraySegment hands out the array + // itself - so pointing it at memory that anything else can read lets one holder rewrite what + // another is about to. That was safe while every reply was single-owner; a cache entry is not. + // See design notes 6.16: share what cannot be written, copy what can. reader.TryGetService(out var pools); var lease = Lease.Create(length, pools?.BufferPool, clear: false); if (reader.TryGetSpan(out var span)) diff --git a/src/StackExchange.Redis/RespReaderLeaseExtensions.cs b/src/StackExchange.Redis/RespReaderLeaseExtensions.cs new file mode 100644 index 000000000..79f7b8870 --- /dev/null +++ b/src/StackExchange.Redis/RespReaderLeaseExtensions.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using RESPite.Buffers; +using RESPite.Messages; + +namespace StackExchange.Redis; + +/// +/// Reads payloads as , sharing the underlying +/// memory where that is possible. +/// +/// +/// +/// A separate class from on purpose. The method it replaces keeps its +/// name, its signature and its containing type - so anything already compiled against it still binds - and +/// merely stops being an extension method. reader.ReadLease() therefore resolves here instead, +/// while RespReaderExtensions.ReadLease(reader) still reaches the old one for anybody who wants a +/// buffer they own outright. +/// +/// +/// That is a source break for callers who named the type (Lease<byte> x = ...) or wrote +/// through it, and deliberately so: those are exactly the callers for whom sharing would have been unsafe, +/// and a compile error is how they find out. It is not a binary break; a rebuild on upgrade picks up the +/// copy-safe version. +/// +/// +public static class RespReaderLeaseExtensions +{ + /// + /// Read a scalar value as a , sharing the reader's buffer when the + /// payload is one contiguous run inside a buffer that supports counted reservations. + /// + /// The reader to read from. + /// The payload, or null for a RESP null. + /// + /// + /// Sharing is safe here for a reason the mutable cannot offer: nothing can write + /// through this lease, so a second holder of the same memory cannot be surprised by it. + /// + /// + /// Sharing does pin - the buffer stays alive until the lease is disposed, so a small payload can + /// hold a large reply. That cost is real for a reply that would otherwise be recycled immediately, and + /// absent for a cached one, which is held for its own lifetime regardless. Use + /// when a small value has to outlive a large reply. + /// + /// + /// Copies when it must: a streamed scalar arrives in chunks and has to be assembled before it can be a + /// single contiguous run. + /// + /// + public static ReadOnlyLease? ReadLease(this in RespReader reader) + { + reader.DemandScalar(); + if (reader.IsNull) return null; + + var length = reader.ScalarLength(); + if (length == 0) return ReadOnlyLease.Empty; + + if (reader.TryReservePayload(out var reservation)) + { + Debug.Assert(reservation.Length == length, "reserved length mismatch"); + return ReadOnlyLease.Share(reservation.Owner, reservation.Offset, reservation.Length); + } + + // no reservation available - a chunked payload, or a buffer that does not support counted + // reservations - so assemble a copy, renting from the pool the data itself came from + reader.TryGetService(out var pools); + var lease = ReadOnlyLease.Rent(length, pools?.BufferPool, out var target); + if (reader.TryGetSpan(out var span)) + { + span.CopyTo(target); + } + else + { + var buffer = reader.Buffer(target); + Debug.Assert(buffer.Length == length, "buffer length mismatch"); + } + + return lease; + } +} diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs index bcbcc2021..bd1244293 100644 --- a/src/StackExchange.Redis/ResultProcessor.Lease.cs +++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs @@ -163,7 +163,11 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { if (reader.IsScalar) { - SetResult(message, reader.ReadLease()!); + // the retired spelling on purpose: this processor's contract is Lease, which the + // caller owns and may write to, so it must be a copy. See design notes 6.16. +#pragma warning disable CS0618 // Type or member is obsolete + SetResult(message, RespReaderExtensions.ReadLease(in reader)!); +#pragma warning restore CS0618 return true; } return false; @@ -178,7 +182,11 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes && reader.TryMoveNext() && reader.IsScalar) { // treat an array of 1 like a single reply - SetResult(message, reader.ReadLease()!); + // the retired spelling on purpose: this processor's contract is Lease, which the + // caller owns and may write to, so it must be a copy. See design notes 6.16. +#pragma warning disable CS0618 // Type or member is obsolete + SetResult(message, RespReaderExtensions.ReadLease(in reader)!); +#pragma warning restore CS0618 return true; } return false; diff --git a/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs b/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs new file mode 100644 index 000000000..29c70c4ad --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Text; +using RESPite.Messages; +using StackExchange.Redis; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// : the lease that may share, because nothing can write through it. +/// +/// +/// The pair expresses one rule - share what cannot be written, copy what can. See design notes 6.16. +/// +public class ReadOnlyLeaseTests +{ + private static RespResult Reply(string raw) => RespResult.Capture(Encoding.UTF8.GetBytes(raw)); + + [Fact] + public void ItSharesTheReplyBufferRatherThanCopying() + { + // the whole point: one more reference to the reply, not a second copy of it + using var reply = Reply("$5\r\nhello\r\n"); + Assert.Equal(1, reply.RefCount); + + var reader = reply.ReadScalar(); + using var lease = reader.ReadLease(); + + Assert.NotNull(lease); + Assert.Equal("hello", Encoding.UTF8.GetString(lease!.Span.ToArray())); + Assert.Equal(2, reply.RefCount); // shared, not copied + } + + [Fact] + public void DisposingTheLeaseGivesTheReferenceBack() + { + using var reply = Reply("$5\r\nhello\r\n"); + var reader = reply.ReadScalar(); + + var lease = reader.ReadLease(); + Assert.Equal(2, reply.RefCount); + + lease!.Dispose(); + Assert.Equal(1, reply.RefCount); + + // once-only, however many times it is called - the release underneath is not idempotent + lease.Dispose(); + lease.Dispose(); + Assert.Equal(1, reply.RefCount); + } + + [Fact] + public void TheMutableLeaseCopiesInstead() + { + // a Lease is writable - Span, Memory and ArraySegment all - so it must never point at memory + // anything else can read. It therefore takes no reference from the reply. + using var reply = Reply("$5\r\nhello\r\n"); + var reader = reply.ReadScalar(); + +#pragma warning disable CS0618 // retired on purpose; this test is what it was retired FOR + using var lease = RespReaderExtensions.ReadLease(in reader); +#pragma warning restore CS0618 + + Assert.NotNull(lease); + Assert.Equal("hello", Encoding.UTF8.GetString(lease!.Span.ToArray())); + Assert.Equal(1, reply.RefCount); // copied: the reply is untouched + } + + [Fact] + public void WritingThroughTheMutableLeaseCannotReachTheReply() + { + // the concrete reason for the split: scribble on the mutable lease and the reply is unharmed + using var reply = Reply("$5\r\nhello\r\n"); + var reader = reply.ReadScalar(); + +#pragma warning disable CS0618 + using var mutable = RespReaderExtensions.ReadLease(in reader); +#pragma warning restore CS0618 + mutable!.Span.Fill((byte)'X'); + + Assert.Equal("hello", reply.ReadScalar().ReadString()); + } + + [Fact] + public void ToArrayLetsASmallValueOutliveALargeReply() + { + // sharing pins: the lease holds the whole reply alive. ToArray is the way out when a small value + // has to outlive a large one. + var big = new string('x', 4096); + byte[] copied; + using (var reply = Reply($"$1\r\na\r\n")) + { + var reader = reply.ReadScalar(); + using var lease = reader.ReadLease(); + copied = lease!.ToArray(); + } + + Assert.Equal("a", Encoding.UTF8.GetString(copied)); + GC.KeepAlive(big); + } + + [Fact] + public void ANullScalarIsNull() + { + using var reply = Reply("$-1\r\n"); + var reader = reply.Read(); + Assert.Null(reader.ReadLease()); + } + + [Fact] + public void AnEmptyScalarIsTheSharedEmpty() + { + using var reply = Reply("$0\r\n\r\n"); + var reader = reply.ReadScalar(); + + var lease = reader.ReadLease(); + Assert.Same(ReadOnlyLease.Empty, lease); + Assert.Equal(1, reply.RefCount); // nothing to share + } +} diff --git a/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs b/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs index 64ae82589..ddda30bfb 100644 --- a/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs +++ b/tests/StackExchange.Redis.Tests/RespResultLeaseSharingTests.cs @@ -112,22 +112,22 @@ public async Task TwoLeasesFromOneResultAreIndependent() [Fact] public async Task SharedLeaseStillSupportsArraySegmentConsumers() { - // DecodeString and AsStream both go via Lease.ArraySegment; a shared lease is backed by a - // MemoryManager rather than an array directly, so this is the case most at risk of regressing + // DecodeString and AsStream reach the backing array internally (MemoryMarshal.TryGetArray), and a + // SHARED lease is backed by a MemoryManager sitting at a non-zero offset inside the reply - which + // is the case most at risk of regressing. ReadOnlyLease deliberately exposes no ArraySegment of its + // own: handing out the array is a way to reach outside the lease, and for shared memory that means + // into somebody else's data. See design notes 6.16. var (conn, result, expected) = await GetBlobAsync(); await using var _ = conn; using (result) { using var lease = result.ReadScalar().ReadLease(); - var segment = lease!.ArraySegment; - Assert.True(segment.Offset > 0, "payload should sit at a non-zero offset within the reply"); - Assert.Equal(expected.Length, segment.Count); - Assert.Equal(expected, Encoding.UTF8.GetString(segment.Array!, segment.Offset, segment.Count)); - + Assert.Equal(expected.Length, lease!.Length); + Assert.Equal(expected, Encoding.UTF8.GetString(lease.Span.ToArray())); Assert.Equal(expected, lease.DecodeString()); - using var stream = lease.AsStream(ownsLease: false); + using var stream = lease.AsStream(ownsLease: false)!; using var reader = new System.IO.StreamReader(stream); Assert.Equal(expected, reader.ReadToEnd()); } @@ -159,7 +159,7 @@ public async Task EmptyPayloadUsesTheSharedEmptyLease_AndTakesNoReference() using var result = await db.ExecuteRespAsync("GET", new RedisKeyOrValue[] { key }); using var lease = result.ReadScalar().ReadLease(); - Assert.Same(Lease.Empty, lease); + Assert.Same(ReadOnlyLease.Empty, lease); // the read-only sibling now serves this call site Assert.Equal(1, result.RefCount); // no reference taken, so nothing to strand } From 30d28d70ad2cfdae08c471d6804e30b0a799d6ec Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 17:03:05 +0100 Subject: [PATCH 109/360] Ad-hoc Execute returning RespResult The escape hatch on the new surface: run a command nobody modelled, get the raw reply. RespContext.ExecuteAsync does it, and one IRespTarget extension gives RespDatabase, IDatabase and anything else carrying a context the same thing without being touched - 9.4's argument working rather than asserted. The argument type is the part that matters. RedisKeyOrValue KEEPS key-ness, which Execute(string, object[]) loses to boxing, so an ad-hoc command renders with correct key marks and therefore routes, invalidates and caches exactly like a modelled one. There is a test for precisely that: two ad-hoc reads, one round trip, then OnInvalidate by key and a re-fetch. TransitionalDatabase.ExecuteResp/ExecuteRespAsync are pass-throughs - the interface signature and the context method agree exactly, RedisKeyOrValue included - so an IDatabase caller gets the same behaviour. SER352: 548 -> 546. Named ExecuteAsync, not Execute, because Execute is taken: RespContext.Execute currently returns a rendered FRAME and does not execute anything, while IDatabase.Execute in this same library sends and returns a result. Two opposite meanings for one verb. Queued the rename (Render), which is ~73 mechanical sites and wants doing right after a merge. --- design/interpolated-resp-writer.queue.md | 23 ++- .../Interpolated/RespContext.cs | 59 ++++++++ .../Interpolated/RespSurface.Strings.cs | 21 +++ .../TransitionalDatabase.Execute.cs | 30 ++++ .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../RespAdHocExecuteTests.cs | 136 ++++++++++++++++++ 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Execute.cs create mode 100644 tests/StackExchange.Redis.Tests/RespAdHocExecuteTests.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 646ab870d..f5f81bc35 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -11,6 +11,13 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Now +- [ ] **Free up the name `Execute`.** `RespContext.Execute(...)` currently returns a rendered `RespFrame` — + it does not execute anything — while `IDatabase.Execute` in this same library *sends and returns a + result*. Two opposite meanings for one verb, in one codebase. Rename the frame-returning one + (`Render` reads right) and let `Execute` mean what everybody expects. ~73 call sites, entirely + mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. + Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. + - [ ] **Stale-while-revalidate** (§6.15). Soft/hard thresholds on `CachePolicy`, once-only refresh via an interlocked flag on the entry, clearing on failure with backoff. Prerequisites are in: single-flight is the same interlock, and `CachePolicy` already carries the lifetime. Remember the cap for the @@ -43,6 +50,18 @@ a line saying why, because "we decided not to" is worth as much as "we did". (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without invalidation. +- [ ] **The rest of the `Execute` family on `TransitionalDatabase`.** `ExecuteResp`/`ExecuteRespAsync` are + done (a pass-through; the signatures agree exactly). `Execute`/`ExecuteAsync` returning `RedisResult` + need a `RespResult` -> `RedisResult` step, for which `RespReaderExtensions.ReadRedisResult` already + exists. The `object[]`/`ICollection` overloads lose key-ness to boxing, so they cannot cache; + that is a property of the old signature, not something to fix here. + +- [ ] **`StringGetLease` and the legacy-lease pattern.** The shape to follow: the new surface produces + `ReadOnlyLease` (which may share), and the legacy adapter converts to `Lease` — a copy, + honestly, because the legacy contract promises the caller owns the bytes. That conversion wants a + pooled `ToLease()` on `ReadOnlyLease` rather than `ToArray()`, which allocates outside the pool. + Not added yet, deliberately: no caller, no API. + - [ ] **More command groups**, in `RespSurface..cs` + `TransitionalDatabase..cs` pairs. Mechanical now; `Strings` and `Bitmaps` are the worked examples. SER352 counts what is left. @@ -72,7 +91,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Invalidation delivery proven against a real server (`TrackingExecutor`) — `4f02b657` - [x] Push classification matching `PhysicalConnection` — `1f8e3cbd` - [x] `RespResult` as a built-in result type (the NRedisStack path) — `4b4a7434` -- [x] `ReadOnlyLease`, and retiring the mutable `ReadLease` spelling — this change +- [x] `ReadOnlyLease`, and retiring the mutable `ReadLease` spelling — `9a1a37bf` +- [x] Ad-hoc `ExecuteAsync` returning `RespResult`, on the context and on `IRespTarget`; `ExecuteResp` + wired through `TransitionalDatabase` — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 6546564a3..9b4f91839 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using RESPite; namespace StackExchange.Redis.Interpolated @@ -255,6 +256,64 @@ public RespContext WithServices(object? services) /// services include one. public RespContext WithCache(RespClientCache? cache) => WithServices(cache); + /// + /// Run an arbitrary command and return the raw reply - the escape hatch, for commands this library + /// does not model. + /// + /// The command name; resolved through the command map like any other. + /// The arguments, each already known to be a key or a value. + /// The command's flags. + /// + /// + /// The point of here is that it keeps key-ness, which the older + /// Execute(string, object[]) loses to boxing. So an ad-hoc command renders with correct key + /// marks, which means it can take part in routing, invalidation and the client-side cache exactly as + /// a modelled command does - the difference between plumbing a module library in and actually + /// serving it. + /// + /// + /// Not an async method: the handler is a ref struct and cannot cross an await, + /// so composition finishes synchronously and only the reply is awaited. + /// + /// + public ValueTask ExecuteAsync( + string command, + ReadOnlyMemory args, + CommandFlags flags = CommandFlags.None) + { + var frame = Render(command, args.Span); + return this.SendAsync(ref frame, flags, RespHandlers.Result); + } + + /// Render an ad-hoc command, marking each argument as a key or a value. + private RespFrame Render(string command, ReadOnlySpan args) + { + var handler = new RespCommandHandler(0, args.Length, this, command); + try + { + foreach (var arg in args) + { + // the key/value distinction is the whole reason this signature exists; losing it here + // would quietly cost routing and invalidation + if (arg.IsKey) + { + handler.AppendFormatted(arg.Key); + } + else + { + handler.AppendFormatted(arg.Value); + } + } + + return handler.Complete(); + } + catch + { + handler.Dispose(); // Complete did not happen, so the buffer is still ours + throw; + } + } + /// A context whose cached answers must be no older than . /// The oldest answer this caller will accept. /// diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 6bfc40a4d..490aca183 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -69,6 +69,27 @@ public static partial class RespSurface // ARGUMENTS change the answer (GETEX with a TTL, SET under NX) raises it explicitly and says why. // WithRetryCategory stays public for surfaces outside this assembly, which cannot see the table. + /// + /// Run an arbitrary command and return the raw reply - the escape hatch, reachable from anything + /// that can produce a context. + /// + /// The database, or anything else carrying a context. + /// The command name. + /// The arguments, each already known to be a key or a value. + /// The command's flags. + /// + /// One extension method, and RespDatabase, IDatabase and anything else implementing + /// all gain it without being touched - which is section 9.4's argument + /// working rather than being asserted. See for why the + /// argument type matters. + /// + public static ValueTask ExecuteAsync( + this IRespTarget target, + string command, + ReadOnlyMemory args, + CommandFlags flags = CommandFlags.None) + => target.Context.ExecuteAsync(command, args, flags); + /// GET. /// The string command group. /// The key to read. diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Execute.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Execute.cs new file mode 100644 index 000000000..64ff1dfc1 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Execute.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The ad-hoc escape hatch, where it has moved to the RESP context surface. + /// + /// + /// + /// Its own file rather than a command group, because it is not one: this is the route for commands + /// nobody modelled, which is how another library's surface reaches a server through this one. + /// + /// + /// ExecuteResp is a straight pass-through - the interface signature and the context method agree + /// exactly, down to for the arguments, which is the part that matters: + /// it keeps key-ness, so an ad-hoc command routes, invalidates and caches like a modelled one. + /// + /// + internal sealed partial class TransitionalDatabase + { + /// + public RespResult ExecuteResp(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + => Wait(Context.ExecuteAsync(command, args, flags)); + + /// + public Task ExecuteRespAsync(string command, ReadOnlyMemory args, CommandFlags flags = CommandFlags.None) + => Context.ExecuteAsync(command, args, flags).AsTask(); + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f9b79e802..75798c0a6 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -242,3 +242,5 @@ static StackExchange.Redis.RespReaderLeaseExtensions.ReadLease(this in RESPite.M *REMOVED*static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.ReadOnlyLease? bytes, bool ownsLease = true) -> System.IO.Stream? static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? +[SER010]StackExchange.Redis.Interpolated.RespContext.ExecuteAsync(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.ExecuteAsync(this StackExchange.Redis.Interpolated.IRespTarget! target, string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask diff --git a/tests/StackExchange.Redis.Tests/RespAdHocExecuteTests.cs b/tests/StackExchange.Redis.Tests/RespAdHocExecuteTests.cs new file mode 100644 index 000000000..1a10b4a48 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespAdHocExecuteTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Messages; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The ad-hoc escape hatch: run a command this library does not model, get the raw reply back. +/// +/// +/// How another library's commands reach this surface. The argument type is the interesting part - see +/// . +/// +public class RespAdHocExecuteTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + internal System.Collections.Generic.List Sent { get; } = []; + + /// The keys the writer marked, captured at send time - the request is recycled after. + internal System.Collections.Generic.List Keys { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + + var count = request.KeyCount; + if (count > 0) + { + var ranges = new KeyRange[count]; + if (request.TryGetKeys(ranges) == count) + { + foreach (var range in ranges) Keys.Add(Encoding.UTF8.GetString(request.GetKey(range).ToArray())); + } + } + + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static RespContext Context(FakeExecutor executor, RespClientCache? cache = null) + => new RespContext().WithExecutor(executor).WithCache(cache); + + [Fact] + public async Task AnUnmodelledCommandRoundTrips() + { + var executor = new FakeExecutor("*2\r\n$3\r\ndoc\r\n:1\r\n"); + RedisKeyOrValue[] args = [RedisKeyOrValue.FromKey("idx"), RedisKeyOrValue.FromValue("@title:hello")]; + + using var result = await Context(executor).ExecuteAsync("FT.SEARCH", args); + + Assert.Equal("*3|$9|FT.SEARCH|$3|idx|$12|@title:hello|", Assert.Single(executor.Sent)); + Assert.Equal(RespPrefix.Array, result.Prefix); + } + + [Fact] + public async Task ArgumentsKeepTheirKeyNess() + { + // the reason for RedisKeyOrValue rather than object[]: boxing loses key-ness, and with it routing, + // invalidation, and any chance of caching an ad-hoc command correctly + var executor = new FakeExecutor("+OK\r\n"); + RedisKeyOrValue[] args = [RedisKeyOrValue.FromKey("thekey"), RedisKeyOrValue.FromValue("thevalue")]; + + using var _ = await Context(executor).ExecuteAsync("JSON.SET", args); + + // exactly one of the two arguments was marked as a key, and it was the right one + Assert.Equal("thekey", Assert.Single(executor.Keys)); + } + + [Fact] + public async Task ItIsReachableFromAnythingCarryingAContext() + { + // one extension on IRespTarget, and every database gets it without being touched + var executor = new FakeExecutor("$3\r\nabc\r\n"); + IRespTarget target = new RespDatabase(Context(executor)); + + using var result = await target.ExecuteAsync("SOME.COMMAND", new[] { RedisKeyOrValue.FromValue("x") }); + + Assert.Equal("abc", result.ReadScalar().ReadString()); + } + + [Fact] + public async Task AnAdHocReadCanBeCachedAndInvalidated() + { + // the payoff of keeping key-ness: an unmodelled command participates in the cache like any other + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$3\r\nabc\r\n", "$3\r\nxyz\r\n"); + var context = Context(executor, cache); + RedisKeyOrValue[] args = [RedisKeyOrValue.FromKey("k")]; + + (await context.ExecuteAsync("MODULE.GET", args, CommandFlags.CommandRetryReadOnly)).Dispose(); + (await context.ExecuteAsync("MODULE.GET", args, CommandFlags.CommandRetryReadOnly)).Dispose(); + Assert.Single(executor.Sent); // served from cache + + Assert.True(cache.OnInvalidate(Encoding.UTF8.GetBytes("k"))); + + using var fresh = await context.ExecuteAsync("MODULE.GET", args, CommandFlags.CommandRetryReadOnly); + Assert.Equal(2, executor.Sent.Count); // invalidated by key, and re-fetched + Assert.Equal("xyz", fresh.ReadScalar().ReadString()); + } + + [Fact] + public async Task TheLegacyInterfaceReachesTheSamePath() + { + // ExecuteResp's signature and the context method agree exactly, RedisKeyOrValue included - so the + // adapter is a pass-through, and an IDatabase caller gets the key-marking behaviour for free + var executor = new FakeExecutor("$3\r\nabc\r\n"); + IDatabase db = new RespDatabase(Context(executor)).AsDatabase(NSubstitute.Substitute.For()); + + using var result = await db.ExecuteRespAsync("MODULE.GET", new[] { RedisKeyOrValue.FromKey("k") }); + + Assert.Equal("abc", result.ReadScalar().ReadString()); + Assert.Equal("k", Assert.Single(executor.Keys)); + } + + [Fact] + public async Task NoArgumentsIsFine() + { + var executor = new FakeExecutor("+PONG\r\n"); + using var result = await Context(executor).ExecuteAsync("PING", default); + + Assert.Equal("*1|$4|PING|", Assert.Single(executor.Sent)); + Assert.Equal("PONG", result.ReadScalar().ReadString()); + } +} From 1e21fadf8ff3d31e6263911d4674154f364ecfb9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:56:49 +0100 Subject: [PATCH 110/360] An open span hole, so the vocabulary stops growing per data type The strings tranche added a span hole for keys and one for key/value pairs. Hashes would have wanted two more - fields, and field/value pairs - and streams and sorted sets would each want their own after that, which is the shape of a problem rather than a feature. So there are two additions here, not four. `ReadOnlySpan` is the twin of the key overload and has to be its own spelling, because the difference between a run of keys and a run of values is the prefix, the invalidation mark and the cross-slot check - none of which can be inferred from the element type. And `ReadOnlySpan where T : IRespArgument` is the open one: the element decides what it writes, so `HashEntry` contributes two arguments, a stream entry will contribute more, and another library's type contributes whatever it likes, all through the same hole and with no further additions here. `HashEntry` therefore implements `IRespArgument` - explicitly, as `Expiration` and `ValueCondition` do, so it does not clutter the type for callers who will never write a frame by hand. A constrained call on a value type, so a struct element does not box; that is the same measurement that made the single-value `AppendFormatted` acceptable, and it matters more per element than per call. --- src/StackExchange.Redis/APITypes/HashEntry.cs | 21 +++++++- .../Interpolated/RespCommandHandler.cs | 48 +++++++++++++++++++ .../Interpolated/RespLiterals.cs | 39 +++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/APITypes/HashEntry.cs b/src/StackExchange.Redis/APITypes/HashEntry.cs index c985aeb68..4cc3c0d6e 100644 --- a/src/StackExchange.Redis/APITypes/HashEntry.cs +++ b/src/StackExchange.Redis/APITypes/HashEntry.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis; /// /// Describes a hash-field (a name/value pair). /// -public readonly struct HashEntry : IEquatable +public readonly struct HashEntry : IEquatable, Interpolated.IRespArgument { internal readonly RedisValue name, value; @@ -56,6 +56,25 @@ public static implicit operator HashEntry(KeyValuePair v /// /// A "{name}: {value}" string representation of this entry. /// + /// + /// + /// + /// Two arguments, name then value - the order every hash command wants them in, so + /// $"{RedisCommand.HSETEX}{key}...{entries}" writes a whole field set as one hole. + /// + /// + /// Explicit, so it does not clutter the type for callers who will never write a RESP frame by hand; + /// reached only through a command hole, which is the one place it means anything. See + /// for the same arrangement. + /// + /// + void Interpolated.IRespArgument.WriteTo(scoped ref Interpolated.RespCommandHandler handler) + { + handler.AppendFormatted(name); + handler.AppendFormatted(value); + } + + /// public override string ToString() => name + ": " + value; /// diff --git a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs index 9ef809093..188969224 100644 --- a/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs +++ b/src/StackExchange.Redis/Interpolated/RespCommandHandler.cs @@ -535,6 +535,54 @@ public void AppendFormatted(scoped ReadOnlySpan + /// Append a run of values; none of them keys, and none marked as such: + /// $"{RedisCommand.HMGET}{key}{fields}". + /// + /// The values to append; an empty run appends nothing. + /// + /// The twin of the key overload, and deliberately a separate one rather than something a caller + /// picks: the difference between a run of keys and a run of values is the prefix, the invalidation + /// mark and the cross-slot check, and none of those can be inferred from the element type at the + /// call site - which is exactly why the two spellings are distinct here. + /// + public void AppendFormatted(scoped ReadOnlySpan value) + { + foreach (ref readonly var item in value) + { + AppendFormatted(item); + } + } + + /// + /// Append a run of anything that knows how to write itself: + /// $"{RedisCommand.HSETEX}{key}{entries}". + /// + /// The element type; inferred from the hole. + /// The elements to append; an empty run appends nothing. + /// + /// + /// The open one, and the reason the vocabulary does not have to grow a span overload per data + /// type. A writes two arguments, a stream entry will write more, and + /// another library's type writes whatever it likes - all through the same hole, because the + /// element decides rather than the handler. + /// + /// + /// A constrained call on a value type, so a struct element does not box - the same measurement + /// that made the single-value AppendFormatted<T> acceptable applies here per element, + /// where it matters more. + /// + /// + public void AppendFormatted(scoped ReadOnlySpan value) where T : IRespArgument + { + DemandCommand(); + foreach (ref readonly var item in value) + { + if (item is null) throw new ArgumentNullException(nameof(value)); + item.WriteTo(ref this); + } + } + /// Append a value; not a key, and not marked as one. /// The value to append. public void AppendFormatted(RedisValue value) diff --git a/src/StackExchange.Redis/Interpolated/RespLiterals.cs b/src/StackExchange.Redis/Interpolated/RespLiterals.cs index 1d423de57..55d47f636 100644 --- a/src/StackExchange.Redis/Interpolated/RespLiterals.cs +++ b/src/StackExchange.Redis/Interpolated/RespLiterals.cs @@ -103,5 +103,44 @@ internal static partial class RespLiterals /// The ONE operation of BITOP. [Resp] internal static partial RespFragment One { get; } + + /// + /// The FIELDS keyword of the hash field-lifetime commands; a count and that many field + /// names follow it. + /// + [Resp] + internal static partial RespFragment Fields { get; } + + /// The WITHVALUES operand of HRANDFIELD. + [Resp] + internal static partial RespFragment WithValues { get; } + + /// + /// The FNX field condition of HSETEX. Note it is not NX: the key-level + /// spelling that writes means something else here, so this is a + /// separate token rather than a reuse. + /// + [Resp] + internal static partial RespFragment Fnx { get; } + + /// + [Resp] + internal static partial RespFragment Fxx { get; } + + /// The NX condition of the hash field-expiry commands. + [Resp] + internal static partial RespFragment Nx { get; } + + /// + [Resp] + internal static partial RespFragment Xx { get; } + + /// + [Resp] + internal static partial RespFragment Gt { get; } + + /// + [Resp] + internal static partial RespFragment Lt { get; } } } From 57a769761ca9a7a431754a02f124d2fe8031f8a4 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:56:49 +0100 Subject: [PATCH 111/360] Reply handlers for the hash shapes HashEntry[], long[], ExpireResult[] and PersistResult[] join the built-in set, and two named handlers appear for a shape that is new in kind rather than in type: the FIELDS commands always reply with an array, one element per field, so asking for exactly one field still gets `*1`. SingletonValue and SingletonLease turn that back into the single value the caller asked for. They cannot be the built-in handler for their result type - that one reads a scalar - which is why these two are named rather than registered. HGETALL is the interesting one. RESP2 interleaves name/value and RESP3 may send them jagged, and the existing processor already decides between the two from the CONTENT rather than from the negotiated protocol - so it is reused rather than restated, which is both less code and the only arrangement in which the two readers cannot disagree about the same bytes. --- .../Interpolated/RespSurface.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 5164d1a04..a0b27d608 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -75,6 +75,18 @@ public static class RespHandlers /// The whole reply, undecoded - the general-purpose answer for commands we do not model. public static IRespHandler Result { get; } = new RespResultHandler(); + /// Reads a one-element array reply as the single value it wraps. + /// + /// The FIELDS n commands always reply with an array, one element per field - so asking for + /// exactly one field still gets *1. This is the shape that turns that back into the single + /// value the caller asked for. It cannot be the built-in handler for + /// (that one reads a scalar), which is why it is named. + /// + public static IRespHandler SingletonValue { get; } = new SingletonValueHandler(); + + /// + public static IRespHandler?> SingletonLease { get; } = new SingletonLeaseHandler(); + /// Checks the reply for a server error, and reads nothing else. /// /// What a command with no result still has to do. Without it a failed command would complete @@ -122,6 +134,10 @@ internal static IRespHandler Require() else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementInt64; else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementDouble; else if (typeof(T) == typeof(Lease)) handler = s_nullableInt64Lease; + else if (typeof(T) == typeof(HashEntry[])) handler = s_hashEntries; + else if (typeof(T) == typeof(long[])) handler = s_int64Array; + else if (typeof(T) == typeof(ExpireResult[])) handler = s_expireResults; + else if (typeof(T) == typeof(PersistResult[])) handler = s_persistResults; return (IRespHandler?)handler; } } @@ -278,6 +294,10 @@ private sealed class ReadOnlyLeaseHandler : IRespHandler?> private static readonly IRespHandler> s_incrementInt64 = new IncrementInt64Handler(); private static readonly IRespHandler> s_incrementDouble = new IncrementDoubleHandler(); private static readonly IRespHandler> s_nullableInt64Lease = new NullableInt64LeaseHandler(); + private static readonly IRespHandler s_hashEntries = new HashEntryHandler(); + private static readonly IRespHandler s_int64Array = new Int64ArrayHandler(); + private static readonly IRespHandler s_expireResults = new ExpireResultHandler(); + private static readonly IRespHandler s_persistResults = new PersistResultHandler(); private sealed class DigestHandler : IRespHandler { @@ -355,6 +375,79 @@ private sealed class NullableInt64LeaseHandler : IRespHandler> } } + private sealed class SingletonValueHandler : IRespHandler + { + public RedisValue Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsNull) return RedisValue.Null; // the whole reply, not an element of it + reader.MoveNext(); + return reader.IsNull ? RedisValue.Null : reader.ReadRedisValue(); + } + } + + private sealed class SingletonLeaseHandler : IRespHandler?> + { + public Lease? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsNull) return null; + reader.MoveNext(); + return reader.ReadLease(); + } + } + + private sealed class HashEntryHandler : IRespHandler + { + // RESP2 sends name/value interleaved and RESP3 may send them jagged; the existing processor + // already decides between them from the CONTENT rather than from the negotiated protocol, so + // reusing it is both less code and the only way the two readers cannot disagree. Resp3 is + // passed to enable that detection, not to assert anything about the connection. + private static readonly ResultProcessor.HashEntryArrayProcessor Shape = new(); + + public HashEntry[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return Shape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) + ?? Array.Empty(); + } + } + + private sealed class Int64ArrayHandler : IRespHandler + { + public long[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadPastArray(static (ref r) => r.ReadInt64(), scalar: true) ?? Array.Empty(); + } + } + + private sealed class ExpireResultHandler : IRespHandler + { + public ExpireResult[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadPastArray(static (ref r) => (ExpireResult)r.ReadInt64(), scalar: true) + ?? Array.Empty(); + } + } + + private sealed class PersistResultHandler : IRespHandler + { + public PersistResult[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadPastArray(static (ref r) => (PersistResult)r.ReadInt64(), scalar: true) + ?? Array.Empty(); + } + } + private sealed class IncrementDoubleHandler : IRespHandler> { public StringIncrementResult Parse(ReadOnlySpan response) From ea503acbc4ceeaf04abeb1178f7f18f0ddfaf73a Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:56:56 +0100 Subject: [PATCH 112/360] The hash commands Everything single-frame that IDatabase exposes as Hash*: the reads, the writes, the counters, the per-field lifetimes, and the read/write combinations. HSCAN stays with the other cursors, and HashImport stays behind because it needs a connection-local PREPARE injected ahead of it - a property of the write path rather than of the command, and one a frame has no way to carry. This is where the Expiration collapse pays for itself. Sixteen overloads become three methods: - HashFieldExpire has four overloads across TimeSpan/DateTime, and picks between HEXPIRE, HPEXPIRE, HEXPIREAT and HPEXPIREAT. Relative-versus-absolute and seconds-versus-milliseconds are both already decided by Expiration, which normalises a whole number of seconds on the way in - so one parameter says all of it. Note this is the one place Expiration is READ rather than written: the mode lives in the command name here, not in an operand, and the argument changes units along with it, so the two have to be chosen together. - HashFieldGetAndSetExpiry has six, and HashFieldSetAndSetExpiry another six, for the same reason plus `persist`/`keepTtl` booleans that Expiration already spells. Two things deliberately did NOT collapse, and the difference is the point: **HSETNX survives where SETNX did not.** `SET ... NX` exists and replies the way `SET` does, so `SETNX` was a pure spelling relic. `HSET` has no NX operand at all; the nearest thing is `HSETEX ... FNX`, which is a different command. **HSETEX is not folded into Set**, though it can express everything HSET can. The booleans answer different questions: HSET replies with how many fields were NEW, HSETEX with whether the write HAPPENED. Routing HSET through HSETEX would turn "this field was new" into a constant true - a loss of information, not a change of spelling, which is exactly what separates it from the relics that did get folded away. Its condition is FNX/FXX rather than NX/XX for the same kind of reason, so `When` is the right vocabulary there and `ValueCondition` is not. SER352 is down from 548 unimplemented members to 472. --- .../Interpolated/RespSurface.Hashes.cs | 585 ++++++++++++++++++ .../Interpolated/RespSurface.Strings.cs | 10 + .../TransitionalDatabase.Hashes.cs | 350 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 42 ++ 4 files changed, 987 insertions(+) create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs new file mode 100644 index 000000000..3d119a331 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -0,0 +1,585 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The hash-command group: target.Hashes.Get(...). + /// + /// + /// + /// The group where the collapse pays for itself most: the old surface spells + /// the field-lifetime commands as TimeSpan? overloads, DateTime overloads and separate + /// persist/keepTtl booleans, which is four methods where there is one request. One + /// parameter says all of it, and picks between HEXPIRE, + /// HPEXPIRE, HEXPIREAT and HPEXPIREAT on the way past. + /// + /// + /// Two things stay behind. HSCAN/HSCANNOVALUES are deferred-execution cursors, which is + /// not a frame; and HIMPORT needs its PREPARE injected onto the same physical connection, + /// which is a property of the write path rather than of the command. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespHashes + { + private readonly RespContext _context; + + /// Group the hash commands of a context. + /// The context to send through. + public RespHashes(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The hash commands. + public RespHashes Hashes => new(target.Context); + } + + extension(in RespContext context) + { + /// The hash commands. + public RespHashes Hashes => new(context); + } + + // ---- reads ------------------------------------------------------------------------------------- + + /// HGET. + /// The hash command group. + /// The key to read. + /// The field to read. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Get(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HGET}{key}{field}", flags.WithDefaultCategory(RedisCommand.HGET)); + + /// HMGET. + /// The hash command group. + /// The key to read. + /// The fields to read. + /// Command flags. + /// + /// No fields means no command, as elsewhere: an arity-zero HMGET is a server error, and the + /// values of no fields is an empty array without asking anyone. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Get(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HMGET}{key}{fields}", flags.WithDefaultCategory(RedisCommand.HMGET)); + + /// HGET, retaining the payload as a rather than a value. + /// The hash command group. + /// The key to read. + /// The field to read. + /// Command flags. + /// The lease must be disposed. +#pragma warning disable RS0026 // the hash group's members share names with other groups' extension methods, but not receiver types + public static ValueTask?> GetLease(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync?>( + $"{RedisCommand.HGET}{key}{field}", flags.WithDefaultCategory(RedisCommand.HGET)); + + /// HGETALL. + /// The hash command group. + /// The key to read. + /// Command flags. + public static ValueTask GetAll(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HGETALL}{key}", flags.WithDefaultCategory(RedisCommand.HGETALL)); + + /// HKEYS. + /// The hash command group. + /// The key to read. + /// Command flags. + public static ValueTask Keys(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HKEYS}{key}", flags.WithDefaultCategory(RedisCommand.HKEYS)); + + /// HVALS. + /// The hash command group. + /// The key to read. + /// Command flags. + public static ValueTask Values(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HVALS}{key}", flags.WithDefaultCategory(RedisCommand.HVALS)); + + /// HLEN: how many fields the hash has. + /// The hash command group. + /// The key to measure. + /// Command flags. +#pragma warning disable RS0026 // the hash group's members share names with other groups' extension methods, but not receiver types + public static ValueTask Length(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HLEN}{key}", flags.WithDefaultCategory(RedisCommand.HLEN)); + + /// HSTRLEN: how long one field's value is. + /// The hash command group. + /// The key to read. + /// The field to measure. + /// Command flags. + public static ValueTask StringLength(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HSTRLEN}{key}{field}", flags.WithDefaultCategory(RedisCommand.HSTRLEN)); + + /// HEXISTS. + /// The hash command group. + /// The key to read. + /// The field to look for. + /// Command flags. + public static ValueTask Exists(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HEXISTS}{key}{field}", flags.WithDefaultCategory(RedisCommand.HEXISTS)); + + /// HRANDFIELD: one field name, at random. + /// The hash command group. + /// The key to read. + /// Command flags. + public static ValueTask RandomField(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HRANDFIELD}{key}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + + /// HRANDFIELD with a count. + /// The hash command group. + /// The key to read. + /// How many to take; a negative count allows repeats. + /// Command flags. + public static ValueTask RandomFields(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + + /// HRANDFIELD ... WITHVALUES. + /// The hash command group. + /// The key to read. + /// How many to take; a negative count allows repeats. + /// Command flags. + public static ValueTask RandomFieldsWithValues(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HRANDFIELD}{key}{count}{RespLiterals.WithValues}", + flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + + // ---- writes ------------------------------------------------------------------------------------ + + /// HSET, or HSETNX under a condition. + /// The hash command group. + /// The key to write. + /// The field to write. + /// The value to write. + /// Whether the field must be absent; default to write unconditionally. + /// Command flags. + /// + /// + /// HSETNX survives here, where SETNX did not, and the difference is instructive: + /// SET ... NX exists and replies the same way SET does, so SETNX was a pure + /// spelling relic. HSET has no NX operand at all - the nearest thing is + /// HSETEX ... FNX, whose boolean answers a different question (see + /// ). + /// So the two commands stay two commands. + /// + /// + /// A null value deletes the field, as on the old surface and for the same reason it does for + /// SET: there is no way to store "no value", and an empty string is a different one. + /// + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Set( + this in RespHashes hashes, + RedisKey key, + RedisValue field, + RedisValue value, + When when = When.Always, + CommandFlags flags = CommandFlags.None) + { + if (value.IsNull) return Delete(in hashes, key, field, flags); + + var command = when switch + { + When.Always => RedisCommand.HSET, + When.NotExists => RedisCommand.HSETNX, + _ => ThrowWhen(when), + }; + + return hashes.Context.SendAsync($"{command}{key}{field}{value}", flags.WithDefaultCategory(command)); + } +#pragma warning restore RS0026 + + /// HMSET: set several fields in one command. + /// The hash command group. + /// The key to write. + /// The fields to write. + /// Command flags. + /// + /// Result-less, as on the old surface: HMSET replies +OK and nothing else, so there + /// is nothing to return - but the reply is still read, because a server error is the only thing + /// such a call can report. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Set(this in RespHashes hashes, RedisKey key, ReadOnlySpan entries, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => entries.IsEmpty + ? default + : hashes.Context.SendAsync( + $"{RedisCommand.HMSET}{key}{entries}", flags.WithDefaultCategory(RedisCommand.HMSET)); + + /// HDEL. + /// The hash command group. + /// The key to write. + /// The field to remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Delete(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HDEL}{key}{field}", flags.WithDefaultCategory(RedisCommand.HDEL)); + + /// HDEL with several fields; the reply is how many were removed. + /// The hash command group. + /// The key to write. + /// The fields to remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Delete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => fields.IsEmpty + ? new ValueTask(0L) + : hashes.Context.SendAsync( + $"{RedisCommand.HDEL}{key}{fields}", flags.WithDefaultCategory(RedisCommand.HDEL)); + + /// HINCRBY, and HINCRBYFLOAT for the floating-point twin. + /// The hash command group. + /// The key to write. + /// The field to increment. + /// The amount to add. + /// Command flags. + /// + /// There is no Decrement, for the reason there is none on + /// : the server has no + /// HDECRBY, and the old surface's HashDecrement is already a negation. + /// +#pragma warning disable RS0026 // long/double are disambiguated by the amount's type + public static ValueTask Increment(this in RespHashes hashes, RedisKey key, RedisValue field, long value = 1, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HINCRBY}{key}{field}{value}", flags.WithDefaultCategory(RedisCommand.HINCRBY)); + + /// + /// The hash command group. + /// The key to write. + /// The field to increment. + /// The amount to add. + /// Command flags. +#pragma warning disable RS0026 // long/double are disambiguated by the amount's type + public static ValueTask Increment(this in RespHashes hashes, RedisKey key, RedisValue field, double value, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HINCRBYFLOAT}{key}{field}{value}", flags.WithDefaultCategory(RedisCommand.HINCRBYFLOAT)); + + // ---- per-field lifetimes ----------------------------------------------------------------------- + + /// HEXPIRE/HPEXPIRE/HEXPIREAT/HPEXPIREAT: give fields a deadline. + /// The hash command group. + /// The key to write. + /// The fields to expire. + /// When the fields should expire. + /// The condition the deadline is subject to. + /// Command flags. + /// + /// + /// Four commands and two overloads become one method. Relative-versus-absolute and + /// seconds-versus-milliseconds are both already decided by - it normalises + /// a whole number of seconds to the second form on the way in - and here those two bits pick the + /// command name rather than an operand token, which is the only thing that makes this group's + /// expiry different from SET's. + /// + /// + /// and have no spelling: the + /// first is not a deadline and the second is , which is a different command + /// with a different reply. An absent expiry is likewise not a request. + /// + /// + public static ValueTask Expire( + this in RespHashes hashes, + RedisKey key, + ReadOnlySpan fields, + Expiration expiry, + ExpireWhen when = ExpireWhen.Always, + CommandFlags flags = CommandFlags.None) + { + if (fields.IsEmpty) return new ValueTask(Array.Empty()); + + var command = SelectExpireCommand(expiry); + return hashes.Context.SendAsync( + $"{command}{key}{expiry.Value}{AsFragment(when)}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithRetryCategory(when.AsRetryCategory()).WithDefaultCategory(command)); + } + + /// HPERSIST: remove the fields' deadlines. + /// The hash command group. + /// The key to write. + /// The fields to persist. + /// Command flags. + public static ValueTask Persist(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HPERSIST}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPERSIST)); + + /// HPTTL: how long the fields have left, in milliseconds. + /// The hash command group. + /// The key to read. + /// The fields to ask about. + /// Command flags. + /// + /// Always the millisecond command, as on the old surface: a caller who wanted seconds can divide, + /// and a caller who needed milliseconds could not recover them. + /// + public static ValueTask GetTimeToLive(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HPTTL}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPTTL)); + + /// HPEXPIRETIME: when the fields expire, as a Unix time in milliseconds. + /// The hash command group. + /// The key to read. + /// The fields to ask about. + /// Command flags. + /// + public static ValueTask GetExpireDateTime(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HPEXPIRETIME}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPEXPIRETIME)); + + // ---- read/write combinations ------------------------------------------------------------------- + + /// HGETDEL: read a field and remove it. + /// The hash command group. + /// The key to write. + /// The field to read and remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask GetDelete(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HGETDEL}{key}{RespLiterals.Fields}{1}{field}", + flags.WithDefaultCategory(RedisCommand.HGETDEL), + RespHandlers.SingletonValue); + + /// HGETDEL with several fields. + /// The hash command group. + /// The key to write. + /// The fields to read and remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask GetDelete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HGETDEL}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HGETDEL)); + + /// HGETDEL, retaining the payload as a . + /// The hash command group. + /// The key to write. + /// The field to read and remove. + /// Command flags. + /// The lease must be disposed. + public static ValueTask?> GetLeaseDelete(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HGETDEL}{key}{RespLiterals.Fields}{1}{field}", + flags.WithDefaultCategory(RedisCommand.HGETDEL), + RespHandlers.SingletonLease); + + /// HGETEX: read a field, and set, keep or clear its expiration in the same call. + /// The hash command group. + /// The key to read. + /// The field to read. + /// + /// The expiration to apply; leaves it untouched, and + /// clears it. + /// + /// Command flags. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask GetSetExpiry(this in RespHashes hashes, RedisKey key, RedisValue field, Expiration expiry = default, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => hashes.Context.SendAsync( + $"{RedisCommand.HGETEX}{key}{expiry}{RespLiterals.Fields}{1}{field}", + WithGetExCategory(expiry, flags), + RespHandlers.SingletonValue); + + /// HGETEX with several fields. + /// The hash command group. + /// The key to read. + /// The fields to read. + /// The expiration to apply. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask GetSetExpiry(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => fields.IsEmpty + ? new ValueTask(Array.Empty()) + : hashes.Context.SendAsync( + $"{RedisCommand.HGETEX}{key}{expiry}{RespLiterals.Fields}{fields.Length}{fields}", + WithGetExCategory(expiry, flags)); + + /// HGETEX, retaining the payload as a . + /// The hash command group. + /// The key to read. + /// The field to read. + /// The expiration to apply. + /// Command flags. + /// The lease must be disposed. + public static ValueTask?> GetLeaseSetExpiry(this in RespHashes hashes, RedisKey key, RedisValue field, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync( + $"{RedisCommand.HGETEX}{key}{expiry}{RespLiterals.Fields}{1}{field}", + WithGetExCategory(expiry, flags), + RespHandlers.SingletonLease); + + /// HSETEX: write a field and its expiration in one command. + /// The hash command group. + /// The key to write. + /// The field to write. + /// The value to write. + /// When the field should expire; default for no expiration. + /// Whether the field must already exist, or must not. + /// Command flags. + /// + /// + /// Deliberately not folded into , + /// even though it can express everything HSET can. The two booleans answer different questions: + /// HSET replies with how many fields were new, while HSETEX replies with + /// whether the write happened. Routing HSET through here would turn "this field was new" + /// into a constant - a loss of information, not a change of spelling, which + /// is what separates this from the SETEX and INCR relics that did get folded away. + /// + /// + /// The condition is FNX/FXX, not NX/XX: the key-level tokens mean + /// something else here, so is not the right vocabulary and + /// is. + /// + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask SetWithExpiry( + this in RespHashes hashes, + RedisKey key, + RedisValue field, + RedisValue value, + Expiration expiry = default, + When when = When.Always, + CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + { + expiry.GetTokenCount(allowEnx: false); // HSETEX has no ENX; say so here rather than on the wire + return hashes.Context.SendAsync( + $"{RedisCommand.HSETEX}{key}{AsFieldCondition(when)}{expiry}{RespLiterals.Fields}{1}{field}{value}", + flags.WithRetryCategory(when.AsRetryCategory()).WithDefaultCategory(RedisCommand.HSETEX)); + } + + /// HSETEX with several fields. + /// The hash command group. + /// The key to write. + /// The fields to write. + /// When the fields should expire; default for no expiration. + /// Whether the fields must already exist, or must not. + /// Command flags. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask SetWithExpiry( + this in RespHashes hashes, + RedisKey key, + ReadOnlySpan entries, + Expiration expiry = default, + When when = When.Always, + CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + { + if (entries.IsEmpty) return new ValueTask(false); + + expiry.GetTokenCount(allowEnx: false); + return hashes.Context.SendAsync( + $"{RedisCommand.HSETEX}{key}{AsFieldCondition(when)}{expiry}{RespLiterals.Fields}{entries.Length}{entries}", + flags.WithRetryCategory(when.AsRetryCategory()).WithDefaultCategory(RedisCommand.HSETEX)); + } + + // ---- shared ------------------------------------------------------------------------------------- + + /// + /// Which of the four field-expiry commands an asks for. + /// + /// + /// The mode lives in the command name here, not in an operand - so unlike every other use of + /// this reads its shape rather than writing its tokens, and only the + /// numeric goes on the wire. + /// + private static RedisCommand SelectExpireCommand(Expiration expiry) + { + if (expiry.IsKeepTtl || expiry.IsPersist || !(expiry.IsAbsolute || expiry.IsRelative)) + { + throw new ArgumentException( + "A deadline is required; KEEPTTL and PERSIST are not expirations, and PERSIST is a separate command.", + nameof(expiry)); + } + + return expiry.IsAbsolute + ? (expiry.IsMilliseconds ? RedisCommand.HPEXPIREAT : RedisCommand.HEXPIREAT) + : (expiry.IsMilliseconds ? RedisCommand.HPEXPIRE : RedisCommand.HEXPIRE); + } + + /// The NX/XX/GT/LT condition of the field-expiry commands, or nothing. + private static RespFragment AsFragment(ExpireWhen when) => when switch + { + ExpireWhen.Always => default, // a zero-argument fragment: written, contributes nothing + ExpireWhen.HasExpiry => RespLiterals.Xx, + ExpireWhen.HasNoExpiry => RespLiterals.Nx, + ExpireWhen.GreaterThanCurrentExpiry => RespLiterals.Gt, + ExpireWhen.LessThanCurrentExpiry => RespLiterals.Lt, + _ => throw new ArgumentOutOfRangeException(nameof(when)), + }; + + /// The FNX/FXX condition of HSETEX, or nothing. + private static RespFragment AsFieldCondition(When when) => when switch + { + When.Always => default, + When.Exists => RespLiterals.Fxx, + When.NotExists => RespLiterals.Fnx, + _ => throw new ArgumentOutOfRangeException(nameof(when)), + }; + + /// + /// A bare HGETEX is the pure read the table says it is; any expiry operand mutates the TTL + /// and makes it a write. The same rule as GETEX, and the same one RedisDatabase applies. + /// + private static CommandFlags WithGetExCategory(Expiration expiry, CommandFlags flags) + { + if (expiry.GetTokenCount(allowEnx: false) != 0) + { + flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); + } + + return flags.WithDefaultCategory(RedisCommand.HGETEX); + } + + /// Reject a a command has no spelling for. + /// The return type of the call site, which never receives a value. + private static T ThrowWhen(When when) + => throw new ArgumentOutOfRangeException(nameof(when), when, "This command does not support that condition."); + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 490aca183..0413c11ca 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -133,7 +133,9 @@ public static ValueTask Get(this in RespStrings strings, ReadOnlyS /// overload: the two differ only in return type, and C# does not overload on that. The lease must /// be disposed. /// +#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask?> GetLease(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 => strings.Context.SendAsync?>( $"{RedisCommand.GET}{key}", flags.WithDefaultCategory(RedisCommand.GET)); @@ -151,7 +153,9 @@ public static ValueTask GetRange(this in RespStrings strings, RedisK /// The string command group. /// The key to read and remove. /// Command flags. +#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask GetDelete(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.GETDEL}{key}", flags.WithDefaultCategory(RedisCommand.GETDEL)); @@ -177,7 +181,9 @@ public static ValueTask GetDelete(this in RespStrings strings, Redis /// render into a command the server will reject. /// /// +#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask GetSetExpiry(this in RespStrings strings, RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 { var mutatesTtl = expiry.GetTokenCount(allowEnx: false) != 0; if (mutatesTtl) flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); @@ -190,7 +196,9 @@ public static ValueTask GetSetExpiry(this in RespStrings strings, Re /// The string command group. /// The key to measure. /// Command flags. +#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask Length(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.STRLEN}{key}", flags.WithDefaultCategory(RedisCommand.STRLEN)); @@ -419,6 +427,7 @@ public static ValueTask SetAndGet( /// would delete the key the caller was protecting. /// /// +#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask Delete( this in RespStrings strings, RedisKey key, @@ -444,6 +453,7 @@ public static ValueTask Delete( return ThrowUnsupportedCondition>(when, nameof(Delete)); } } +#pragma warning restore RS0026 /// INCRBY, and INCRBYFLOAT for the floating-point twin. /// The string command group. diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs new file mode 100644 index 000000000..147233b48 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs @@ -0,0 +1,350 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The hash commands, where they have moved to the RESP context surface. + /// + /// + /// + /// The clearest illustration so far of what the new surface costs the old one: nothing. Six + /// HashFieldGetAndSetExpiry overloads, six HashFieldSetAndSetExpiry overloads and four + /// HashFieldExpire overloads all land on one group method each, because + /// already says everything their TimeSpan?/DateTime/ + /// persist/keepTtl parameters were spelling out between them. + /// + /// + /// HashScan stays in TransitionalDatabase.Scans.cs, and HashImport stays with the + /// generated members: it needs a connection-local PREPARE injected ahead of it, which is a + /// property of the write path rather than of the command, and the frame path has no way to say it. + /// + /// + internal sealed partial class TransitionalDatabase + { + /// + public RedisValue HashGet(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Get(key, hashField, flags)); + + /// + public Task HashGetAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Get(key, hashField, flags).AsTask(); + + /// + public RedisValue[] HashGet(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Get(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashGetAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Get(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public Lease? HashGetLease(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetLease(key, hashField, flags)); + + /// + public Task?> HashGetLeaseAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetLease(key, hashField, flags).AsTask(); + + /// + public HashEntry[] HashGetAll(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetAll(key, flags)); + + /// + public Task HashGetAllAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetAll(key, flags).AsTask(); + + /// + public RedisValue[] HashKeys(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Keys(key, flags)); + + /// + public Task HashKeysAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Keys(key, flags).AsTask(); + + /// + public RedisValue[] HashValues(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Values(key, flags)); + + /// + public Task HashValuesAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Values(key, flags).AsTask(); + + /// + public long HashLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Length(key, flags)); + + /// + public Task HashLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Length(key, flags).AsTask(); + + /// + public long HashStringLength(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.StringLength(key, hashField, flags)); + + /// + public Task HashStringLengthAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.StringLength(key, hashField, flags).AsTask(); + + /// + public bool HashExists(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Exists(key, hashField, flags)); + + /// + public Task HashExistsAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Exists(key, hashField, flags).AsTask(); + + /// + public RedisValue HashRandomField(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.RandomField(key, flags)); + + /// + public Task HashRandomFieldAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Hashes.RandomField(key, flags).AsTask(); + + /// + public RedisValue[] HashRandomFields(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.RandomFields(key, count, flags)); + + /// + public Task HashRandomFieldsAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.Hashes.RandomFields(key, count, flags).AsTask(); + + /// + public HashEntry[] HashRandomFieldsWithValues(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.RandomFieldsWithValues(key, count, flags)); + + /// + public Task HashRandomFieldsWithValuesAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.Hashes.RandomFieldsWithValues(key, count, flags).AsTask(); + + /// + public bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Set(key, hashField, value, when, flags)); + + /// + public Task HashSetAsync(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Set(key, hashField, value, when, flags).AsTask(); + + /// + public void HashSet(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Set(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Set(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public bool HashDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Delete(key, hashField, flags)); + + /// + public Task HashDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Delete(key, hashField, flags).AsTask(); + + /// + public long HashDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Delete(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Delete(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public long HashIncrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Increment(key, hashField, value, flags)); + + /// + public Task HashIncrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Increment(key, hashField, value, flags).AsTask(); + + /// + public double HashIncrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Increment(key, hashField, value, flags)); + + /// + public Task HashIncrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Increment(key, hashField, value, flags).AsTask(); + + // Decrement is a negation, here as it already was in RedisDatabase; the server has no HDECRBY + + /// + public long HashDecrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => HashIncrement(key, hashField, -value, flags); + + /// + public Task HashDecrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => HashIncrementAsync(key, hashField, -value, flags); + + /// + public double HashDecrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => HashIncrement(key, hashField, -value, flags); + + /// + public Task HashDecrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => HashIncrementAsync(key, hashField, -value, flags); + + // ---- per-field lifetimes ----------------------------------------------------------------------- + // The TimeSpan/DateTime pairs collapse onto one group method: an Expiration built from a TimeSpan + // is relative and one built from a DateTime is absolute, which is precisely the distinction the two + // overloads existed to make. + + /// + public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); + + /// + public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); + + /// + public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); + + /// + public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); + + /// + public PersistResult[] HashFieldPersist(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.Persist(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashFieldPersistAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.Persist(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public long[] HashFieldGetTimeToLive(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetTimeToLive(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashFieldGetTimeToLiveAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetTimeToLive(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public long[] HashFieldGetExpireDateTime(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetExpireDateTime(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashFieldGetExpireDateTimeAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetExpireDateTime(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + // ---- read/write combinations ------------------------------------------------------------------- + + /// + public RedisValue HashFieldGetAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetDelete(key, hashField, flags)); + + /// + public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetDelete(key, hashField, flags).AsTask(); + + /// + public RedisValue[] HashFieldGetAndDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetDelete(key, Required(hashFields, nameof(hashFields)), flags)); + + /// + public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetDelete(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + + /// + public Lease? HashFieldGetLeaseAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetLeaseDelete(key, hashField, flags)); + + /// + public Task?> HashFieldGetLeaseAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetLeaseDelete(key, hashField, flags).AsTask(); + + // HGETEX: a null TimeSpan means "clear the TTL", which is NOT Expiration.Default's "leave it + // alone" - the same mapping RedisDatabase uses, and the same one StringGetSetExpiry needs + + /// + public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetSetExpiry(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags)); + + /// + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetSetExpiry(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags).AsTask(); + + /// + public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetSetExpiry(key, hashField, new Expiration(expiry), flags)); + + /// + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetSetExpiry(key, hashField, new Expiration(expiry), flags).AsTask(); + + /// + public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags)); + + /// + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags).AsTask(); + + /// + public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags)); + + /// + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags).AsTask(); + + /// + public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetLeaseSetExpiry(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags)); + + /// + public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetLeaseSetExpiry(key, hashField, Expiration.CreateOrPersist(expiry, persist), flags).AsTask(); + + /// + public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Wait(Context.Hashes.GetLeaseSetExpiry(key, hashField, new Expiration(expiry), flags)); + + /// + public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => Context.Hashes.GetLeaseSetExpiry(key, hashField, new Expiration(expiry), flags).AsTask(); + + // HSETEX replies with whether the write happened; the old signature says RedisValue, so the + // conversion happens here rather than the group pretending not to know what it read + + /// + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(Wait(Context.Hashes.SetWithExpiry(key, field, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags))); + + /// + public async Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(await Context.Hashes.SetWithExpiry(key, field, value, Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags).ConfigureAwait(false)); + + /// + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(Wait(Context.Hashes.SetWithExpiry(key, field, value, new Expiration(expiry), when, flags))); + + /// + public async Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(await Context.Hashes.SetWithExpiry(key, field, value, new Expiration(expiry), when, flags).ConfigureAwait(false)); + + /// + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(Wait(Context.Hashes.SetWithExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags))); + + /// + public async Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(await Context.Hashes.SetWithExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrKeepTtl(expiry, keepTtl), when, flags).ConfigureAwait(false)); + + /// + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(Wait(Context.Hashes.SetWithExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), when, flags))); + + /// + public async Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => AsValue(await Context.Hashes.SetWithExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), when, flags).ConfigureAwait(false)); + + /// + /// The 1/0 the old surface reports as a for HSETEX; an empty hash-field + /// set never reaches the server, and reports the same "nothing was written" it would have. + /// + private static RedisValue AsValue(bool written) => written ? 1 : 0; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 75798c0a6..502b58587 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -55,6 +55,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendBulk(scoped System.ReadOnlySpan payload) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan> value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value, string? format) -> void @@ -64,6 +65,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler, string? format) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Complete() -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Dispose() -> void @@ -126,6 +128,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespFrameWriter.RespFrameWriter(int capacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespFrameWriter.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespHandlers +[SER010]StackExchange.Redis.Interpolated.RespHashes +[SER010]StackExchange.Redis.Interpolated.RespHashes.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespHashes.RespHashes() -> void +[SER010]StackExchange.Redis.Interpolated.RespHashes.RespHashes(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespPayload [SER010]StackExchange.Redis.Interpolated.RespPayload.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.GetReader() -> RESPite.Messages.RespReader @@ -154,9 +160,11 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool @@ -176,6 +184,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Int64.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Lease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.NullableInt64.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.SingletonLease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.SingletonValue.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.String.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Success.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! @@ -183,34 +193,66 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]static StackExchange.Redis.Interpolated.RespSurface.Append(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Digest(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Exists(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Expire(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, System.ReadOnlySpan operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetAll(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetExpireDateTime(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLease(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLease(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLeaseDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLeaseSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetRange(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long start, long end, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.Expiration expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetTimeToLive(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Keys(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequence(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Operation(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.Bitwise operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomField(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan> values, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetAndGet(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetRange(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.StringLength(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Values(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespBitmaps +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespHashes +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespHashes [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void From 83969c315dee3517d01174adc87b46205be97da7 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:57:03 +0100 Subject: [PATCH 113/360] Re-run the hash suites, and prove the re-runs are real HashTests and HashFieldTests join StringTests and BitTests in running a second time against a TransitionalDatabase: 102 more tests, passing first time, which is what the fire-and-forget and command-identity fixes from the strings tranche bought. **And a check that those re-runs mean anything.** The fallback is what makes TransitionalDatabase a complete IDatabase, and it is equally what would let a suite pass green while quietly still using the old implementation for every command. TransitionalCoverageTests closes that: `[AutoDatabase]` emits EXPLICIT interface implementations, so the interface map distinguishes a hand-written member (maps to a public method) from a generated one (maps to a private explicit method), and every String*/Hash* member is asserted to be the former. The three deliberate exceptions are listed by name, each with its reason. There is a control test alongside it, asserting that an unmoved group IS still reported as generated. Without it the coverage assertion could become vacuous rather than wrong if the interface map ever stopped telling the two apart, and vacuous is the harder failure to notice. One pre-existing landmine surfaced: TestIncrementOnHashThatDoesntExist used a fixed key name. Two classes running it concurrently do not merely overwrite each other - an increment accumulates, so the second to arrive sees 2. Now Me(), like its neighbours. (The BitOp tests write to fixed DESTINATION keys too, but those are overwrites of an identical computed value, so they collide harmlessly.) --- .../HashFieldTests.cs | 52 +++++------ tests/StackExchange.Redis.Tests/HashTests.cs | 59 +++++++------ .../TransitionalSurfaceTests.cs | 86 ++++++++++++++++++- 3 files changed, 143 insertions(+), 54 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/HashFieldTests.cs b/tests/StackExchange.Redis.Tests/HashFieldTests.cs index 2bb98eb85..0b2604fe4 100644 --- a/tests/StackExchange.Redis.Tests/HashFieldTests.cs +++ b/tests/StackExchange.Redis.Tests/HashFieldTests.cs @@ -23,7 +23,7 @@ public class HashFieldTests(ITestOutputHelper output, SharedConnectionFixture fi [Fact] public void HashFieldExpire() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -37,7 +37,7 @@ public void HashFieldExpire() [Fact] public void HashFieldExpireNoKey() { - var db = Create(require: RedisFeatures.v7_4_0_rc2).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc2)); var hashKey = Me(); var fieldsResult = db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -50,7 +50,7 @@ public void HashFieldExpireNoKey() [Fact] public async Task HashFieldExpireAsync() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -64,7 +64,7 @@ public async Task HashFieldExpireAsync() [Fact] public async Task HashFieldExpireAsyncNoKey() { - var db = Create(require: RedisFeatures.v7_4_0_rc2).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc2)); var hashKey = Me(); var fieldsResult = await db.HashFieldExpireAsync(hashKey, fields, oneYearInMs); @@ -77,7 +77,7 @@ public async Task HashFieldExpireAsyncNoKey() [Fact] public void HashFieldGetExpireDateTimeIsDue() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -88,7 +88,7 @@ public void HashFieldGetExpireDateTimeIsDue() [Fact] public void HashFieldExpireNoField() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -99,7 +99,7 @@ public void HashFieldExpireNoField() [Fact] public void HashFieldExpireConditionsSatisfied() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.KeyDelete(hashKey); db.HashSet(hashKey, entries); @@ -123,7 +123,7 @@ public void HashFieldExpireConditionsSatisfied() [Fact] public void HashFieldExpireConditionsNotSatisfied() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.KeyDelete(hashKey); db.HashSet(hashKey, entries); @@ -147,7 +147,7 @@ public void HashFieldExpireConditionsNotSatisfied() [Fact] public void HashFieldGetExpireDateTime() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, nextCentury); @@ -163,7 +163,7 @@ public void HashFieldGetExpireDateTime() [Fact] public void HashFieldExpireFieldNoExpireTime() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -177,7 +177,7 @@ public void HashFieldExpireFieldNoExpireTime() [Fact] public void HashFieldGetExpireDateTimeNoKey() { - var db = Create(require: RedisFeatures.v7_4_0_rc2).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc2)); var hashKey = Me(); var fieldsResult = db.HashFieldGetExpireDateTime(hashKey, fields); @@ -187,7 +187,7 @@ public void HashFieldGetExpireDateTimeNoKey() [Fact] public void HashFieldGetExpireDateTimeNoField() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -199,7 +199,7 @@ public void HashFieldGetExpireDateTimeNoField() [Fact] public void HashFieldGetTimeToLive() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -219,7 +219,7 @@ public void HashFieldGetTimeToLive() [Fact] public void HashFieldGetTimeToLiveNoExpireTime() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -230,7 +230,7 @@ public void HashFieldGetTimeToLiveNoExpireTime() [Fact] public void HashFieldGetTimeToLiveNoKey() { - var db = Create(require: RedisFeatures.v7_4_0_rc2).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc2)); var hashKey = Me(); var fieldsResult = db.HashFieldGetTimeToLive(hashKey, fields); @@ -240,7 +240,7 @@ public void HashFieldGetTimeToLiveNoKey() [Fact] public void HashFieldGetTimeToLiveNoField() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -252,7 +252,7 @@ public void HashFieldGetTimeToLiveNoField() [Fact] public void HashFieldPersist() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -270,7 +270,7 @@ public void HashFieldPersist() [Fact] public void HashFieldPersistNoExpireTime() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); @@ -281,7 +281,7 @@ public void HashFieldPersistNoExpireTime() [Fact] public void HashFieldPersistNoKey() { - var db = Create(require: RedisFeatures.v7_4_0_rc2).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc2)); var hashKey = Me(); var fieldsResult = db.HashFieldPersist(hashKey, fields); @@ -291,7 +291,7 @@ public void HashFieldPersistNoKey() [Fact] public void HashFieldPersistNoField() { - var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + var db = GetDatabase(Create(require: RedisFeatures.v7_4_0_rc1)); var hashKey = Me(); db.HashSet(hashKey, entries); db.HashFieldExpire(hashKey, fields, oneYearInMs); @@ -304,7 +304,7 @@ public void HashFieldPersistNoField() public void HashFieldGetAndSetExpiry() { using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // testing with timespan @@ -354,7 +354,7 @@ public void HashFieldGetAndSetExpiry() public async Task HashFieldGetAndSetExpiryAsync() { await using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // testing with timespan @@ -404,7 +404,7 @@ public async Task HashFieldGetAndSetExpiryAsync() public void HashFieldSetAndSetExpiry() { using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // testing with timespan @@ -468,7 +468,7 @@ public void HashFieldSetAndSetExpiry() public async Task HashFieldSetAndSetExpiryAsync() { await using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // testing with timespan @@ -531,7 +531,7 @@ public async Task HashFieldSetAndSetExpiryAsync() public void HashFieldGetAndDelete() { using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // single field @@ -552,7 +552,7 @@ public void HashFieldGetAndDelete() public async Task HashFieldGetAndDeleteAsync() { await using var conn = Create(require: RedisFeatures.v8_0_0_M04); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); // single field diff --git a/tests/StackExchange.Redis.Tests/HashTests.cs b/tests/StackExchange.Redis.Tests/HashTests.cs index 9252cb669..5a0be5466 100644 --- a/tests/StackExchange.Redis.Tests/HashTests.cs +++ b/tests/StackExchange.Redis.Tests/HashTests.cs @@ -18,7 +18,7 @@ public async Task TestIncrBy() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); _ = db.KeyDeleteAsync(key).ForAwait(); @@ -43,7 +43,7 @@ public async Task ScanAsync() { await using var conn = Create(require: RedisFeatures.v2_8_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); for (int i = 0; i < 200; i++) @@ -89,7 +89,7 @@ public async Task Scan() { await using var conn = Create(require: RedisFeatures.v2_8_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); _ = db.KeyDeleteAsync(key); @@ -127,7 +127,7 @@ public async Task ScanNoValuesAsync() { await using var conn = Create(require: RedisFeatures.v7_4_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); for (int i = 0; i < 200; i++) @@ -173,7 +173,7 @@ public async Task ScanNoValues() { await using var conn = Create(require: RedisFeatures.v7_4_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); _ = db.KeyDeleteAsync(key); @@ -212,11 +212,16 @@ public async Task TestIncrementOnHashThatDoesntExist() { await using var conn = Create(); - var db = conn.GetDatabase(); - _ = db.KeyDeleteAsync("keynotexist"); + var db = GetDatabase(conn); + + // Me(), not a fixed name: an INCREMENT accumulates, so two tests sharing this key do not merely + // overwrite each other - the second one to arrive sees 2 and fails. The key still does not exist, + // which is the only thing the test needs of it. + var key = Me(); + _ = db.KeyDeleteAsync(key); #pragma warning disable SER308 // deliberate: test code blocking on a task, and the Wait helpers apply the configured timeout that a bare await would not - var result1 = db.Wait(db.HashIncrementAsync("keynotexist", "fieldnotexist", 1)); - var result2 = db.Wait(db.HashIncrementAsync("keynotexist", "anotherfieldnotexist", 1)); + var result1 = db.Wait(db.HashIncrementAsync(key, "fieldnotexist", 1)); + var result2 = db.Wait(db.HashIncrementAsync(key, "anotherfieldnotexist", 1)); #pragma warning restore SER308 Assert.Equal(1, result1); Assert.Equal(1, result2); @@ -227,7 +232,7 @@ public async Task TestIncrByFloat() { await using var conn = Create(require: RedisFeatures.v2_6_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); _ = db.KeyDeleteAsync(key).ForAwait(); var aTasks = new Task[1000]; @@ -250,7 +255,7 @@ public async Task TestGetAll() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key).ForAwait(); var shouldMatch = new Dictionary(); @@ -283,7 +288,7 @@ public async Task TestGet() await using var conn = Create(); var key = Me(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var shouldMatch = new Dictionary(); var random = new Random(); @@ -314,7 +319,7 @@ public async Task TestSet() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); var del = db.KeyDeleteAsync(hashkey).ForAwait(); @@ -356,7 +361,7 @@ public async Task TestSetNotExists() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); var del = db.KeyDeleteAsync(hashkey).ForAwait(); @@ -390,7 +395,7 @@ public async Task TestDelSingle() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); await db.KeyDeleteAsync(hashkey).ForAwait(); var del0 = db.HashDeleteAsync(hashkey, "field").ForAwait(); @@ -413,7 +418,7 @@ public async Task TestDelMulti() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); db.HashSet(hashkey, "key1", "val1", flags: CommandFlags.FireAndForget); db.HashSet(hashkey, "key2", "val2", flags: CommandFlags.FireAndForget); @@ -492,7 +497,7 @@ public async Task TestExists() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); _ = db.KeyDeleteAsync(hashkey).ForAwait(); var ex0 = db.HashExistsAsync(hashkey, "field").ForAwait(); @@ -514,7 +519,7 @@ public async Task TestHashKeys() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); await db.KeyDeleteAsync(hashKey).ForAwait(); @@ -540,7 +545,7 @@ public async Task TestHashValues() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); await db.KeyDeleteAsync(hashkey).ForAwait(); @@ -567,7 +572,7 @@ public async Task TestHashLength() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); db.KeyDelete(hashkey, CommandFlags.FireAndForget); @@ -590,7 +595,7 @@ public async Task TestGetMulti() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); db.KeyDelete(hashkey, CommandFlags.FireAndForget); @@ -627,7 +632,7 @@ public async Task TestGetPairs() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); _ = db.KeyDeleteAsync(hashkey); @@ -655,7 +660,7 @@ public async Task TestSetPairs() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); _ = db.KeyDeleteAsync(hashkey).ForAwait(); @@ -684,7 +689,7 @@ public async Task TestWhenAlwaysAsync() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashkey = Me(); db.KeyDelete(hashkey, CommandFlags.FireAndForget); @@ -705,7 +710,7 @@ public async Task HashRandomFieldAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); var items = new HashEntry[] { new("new york", "yankees"), new("baltimore", "orioles"), new("boston", "red sox"), new("Tampa Bay", "rays"), new("Toronto", "blue jays") }; await db.HashSetAsync(hashKey, items); @@ -733,7 +738,7 @@ public async Task HashRandomField() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); var items = new HashEntry[] { new("new york", "yankees"), new("baltimore", "orioles"), new("boston", "red sox"), new("Tampa Bay", "rays"), new("Toronto", "blue jays") }; db.HashSet(hashKey, items); @@ -761,7 +766,7 @@ public async Task HashRandomFieldEmptyHash() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var hashKey = Me(); var singleField = db.HashRandomField(hashKey); diff --git a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs index be0414142..a1b085b96 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs @@ -1,4 +1,7 @@ -using StackExchange.Redis.Interpolated; +using System; +using System.Linq; +using System.Reflection; +using StackExchange.Redis.Interpolated; using Xunit; namespace StackExchange.Redis.Tests; @@ -60,3 +63,84 @@ public class TransitionalBitTests(ITestOutputHelper output, SharedConnectionFixt protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); } + +/// +[RunPerProtocol] +public class TransitionalHashTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : HashTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} + +/// +[RunPerProtocol] +public class TransitionalHashFieldTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : HashFieldTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} + +/// +/// That the re-runs above are actually re-running anything. +/// +/// +/// The fallback makes TransitionalDatabase a complete , which is what lets +/// an existing suite run against it - and is also how a suite could pass without touching the new surface +/// at all, if a command were quietly still being forwarded. So these assert the other half: that the +/// members those suites call are implemented by the class itself rather than generated. +/// +/// [AutoDatabase] emits EXPLICIT interface implementations, so the interface map is what tells the +/// two apart - a hand-written member maps to a public method, a generated one to a private explicit one. +/// Nothing else can see the difference, which is the same reason TransitionalDatabaseTests goes through +/// IDatabase rather than the concrete type. +/// +/// +public class TransitionalCoverageTests +{ + private static string[] Generated(string prefix, Type iface) + { + var map = typeof(TransitionalDatabase).GetInterfaceMap(iface); + return map.InterfaceMethods + .Select((m, i) => (Interface: m, Target: map.TargetMethods[i])) + .Where(x => x.Interface.Name.StartsWith(prefix, StringComparison.Ordinal)) + .Where(x => x.Target.IsPrivate) // an explicit implementation: generated, so it throws or forwards + .Select(x => x.Interface.Name + "(" + string.Join(", ", x.Interface.GetParameters().Select(p => p.ParameterType.Name)) + ")") + .Distinct() + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + } + + [Theory] + [InlineData("String")] + [InlineData("Hash")] + public void EveryMemberOfAMovedGroupIsImplemented(string prefix) + { + var generated = Generated(prefix, typeof(IDatabase)).Concat(Generated(prefix, typeof(IDatabaseAsync))) + .Distinct() + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + // the ones deliberately left behind, each for a reason that is not "not done yet": + // - StringGetWithExpiry pipelines TTL+GET, and a composite is not a frame + // - HashImport needs a connection-local PREPARE injected ahead of it + // - the scans are deferred-execution cursors + var expected = generated + .Where(x => !x.StartsWith("StringGetWithExpiry", StringComparison.Ordinal)) + .Where(x => !x.StartsWith("HashImport", StringComparison.Ordinal)) + .Where(x => !x.StartsWith("HashScan", StringComparison.Ordinal)) + .ToArray(); + + Assert.Empty(expected); + } + + [Fact] + public void AnUnmovedGroupIsStillGenerated() + { + // the control. Without this, EveryMemberOfAMovedGroupIsImplemented would pass just as happily if + // the interface map stopped distinguishing the two kinds of member, and the coverage claim above + // would be vacuous rather than wrong - which is the harder failure to notice. + Assert.NotEmpty(Generated("SortedSet", typeof(IDatabase))); + } +} From 7f6e73874fb53618684767878531759fafa44fde Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:57:03 +0100 Subject: [PATCH 114/360] Pin the hash bytes The re-runs check semantics against real servers; these check the frames, in the cases a server cannot distinguish. So: that a field is written as a value and not a key, so a key prefix does not silently reach it; that HSETEX renders FNX before the expiry before FIELDS; that HGETEX renders the expiry before FIELDS; that a whole-second deadline picks HEXPIREAT and a sub-second one HPEXPIREAT, with the argument changing units to match; that HPTTL and HPEXPIRETIME are always the millisecond commands; that a HashEntry set is one hole rendering name then value; that a single-field reply is unwrapped from its array; and that HGETALL reads both the interleaved and the jagged pair shapes into the same result. Two of my own expectations were wrong and the tests caught them, which is the argument for writing the bytes out by hand rather than asserting whatever the writer happened to produce. --- .../RespSurfaceHashesTests.cs | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs new file mode 100644 index 000000000..bad769d02 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// What the hash group puts on the wire, byte for byte, against a fake executor. +/// +/// +public class RespSurfaceHashesTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public List Flags { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + Flags.Add(request.Flags); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static (RespContext Context, FakeExecutor Executor) Target(params string[] replies) + { + var executor = new FakeExecutor(replies.Length == 0 ? [":1\r\n"] : replies); + return (new RespContext().WithExecutor(executor), executor); + } + + [Fact] + public async Task OneFieldAndManyFieldsAreDifferentCommands() + { + var (ctx, exec) = Target("$3\r\nabc\r\n", "*2\r\n$1\r\na\r\n$1\r\nb\r\n"); + + await ctx.Hashes.Get("k", "f1"); + await ctx.Hashes.Get("k", ["f1", "f2"]); + + Assert.Equal( + new[] { "*3|$4|HGET|$1|k|$2|f1|", "*4|$5|HMGET|$1|k|$2|f1|$2|f2|" }, + exec.Sent); + } + + [Fact] + public async Task FieldsAreValuesAndNotKeys() + { + var (ctx, exec) = Target("*1\r\n$1\r\na\r\n"); + + await ctx.WithKeyPrefix("t:").Hashes.Get("k", ["f1"]); + + // the key is prefixed; the field is not. Writing a field through the key path would silently + // prefix it, and nothing downstream could tell + Assert.Equal("*3|$5|HMGET|$3|t:k|$2|f1|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task NoFieldsMeansNoCommand() + { + var (ctx, exec) = Target(); + + Assert.Empty(await ctx.Hashes.Get("k", ReadOnlySpan.Empty)); + Assert.Equal(0, await ctx.Hashes.Delete("k", ReadOnlySpan.Empty)); + Assert.Empty(await ctx.Hashes.Persist("k", ReadOnlySpan.Empty)); + await ctx.Hashes.Set("k", ReadOnlySpan.Empty); + + Assert.Empty(exec.Sent); + } + + [Fact] + public async Task SetPicksBetweenHSetHSetNxAndHDel() + { + var (ctx, exec) = Target(); + + await ctx.Hashes.Set("k", "f", "v"); + await ctx.Hashes.Set("k", "f", "v", When.NotExists); + await ctx.Hashes.Set("k", "f", RedisValue.Null); + + Assert.Equal( + new[] + { + "*4|$4|HSET|$1|k|$1|f|$1|v|", + "*4|$6|HSETNX|$1|k|$1|f|$1|v|", + "*3|$4|HDEL|$1|k|$1|f|", // a null value removes the field, as on the old surface + }, + exec.Sent); + } + + [Fact] + public async Task AFieldSetIsOneHole() + { + var (ctx, exec) = Target("+OK\r\n"); + + HashEntry[] entries = [new("f1", "v1"), new("f2", "v2")]; + await ctx.Hashes.Set("k", entries); + + // HashEntry writes its own two arguments, name then value, so a whole field set is one hole + Assert.Equal("*6|$5|HMSET|$1|k|$2|f1|$2|v1|$2|f2|$2|v2|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task RandomFieldHasThreeShapes() + { + var (ctx, exec) = Target("$2\r\nf1\r\n", "*1\r\n$2\r\nf1\r\n", "*2\r\n$2\r\nf1\r\n$2\r\nv1\r\n"); + + await ctx.Hashes.RandomField("k"); + await ctx.Hashes.RandomFields("k", -5); + await ctx.Hashes.RandomFieldsWithValues("k", 2); + + Assert.Equal( + new[] + { + "*2|$10|HRANDFIELD|$1|k|", + "*3|$10|HRANDFIELD|$1|k|$2|-5|", + "*4|$10|HRANDFIELD|$1|k|$1|2|$10|WITHVALUES|", + }, + exec.Sent); + } + + [Fact] + public async Task GetAllReadsBothPairShapes() + { + // RESP2 interleaves name/value; RESP3 may send them jagged. The shape is decided from the reply + // rather than from the negotiated protocol, so both arrive as the same HashEntry[]. + var (interleaved, _) = Target("*4\r\n$2\r\nf1\r\n$2\r\nv1\r\n$2\r\nf2\r\n$2\r\nv2\r\n"); + var (jagged, _) = Target("*2\r\n*2\r\n$2\r\nf1\r\n$2\r\nv1\r\n*2\r\n$2\r\nf2\r\n$2\r\nv2\r\n"); + + HashEntry[] expected = [new("f1", "v1"), new("f2", "v2")]; + Assert.Equal(expected, await interleaved.Hashes.GetAll("k")); + Assert.Equal(expected, await jagged.Hashes.GetAll("k")); + } + + [Fact] + public async Task ExpirePicksItsCommandFromTheExpirationsShape() + { + var (ctx, exec) = Target("*1\r\n:1\r\n"); + + RedisValue[] fields = ["f1"]; + await ctx.Hashes.Expire("k", fields, TimeSpan.FromSeconds(300)); // relative, whole seconds + await ctx.Hashes.Expire("k", fields, TimeSpan.FromMilliseconds(1500)); // relative, milliseconds + var whole = new DateTime(2101, 1, 1, 0, 0, 0, DateTimeKind.Utc); + await ctx.Hashes.Expire("k", fields, whole); // absolute, whole seconds + await ctx.Hashes.Expire("k", fields, whole.AddMilliseconds(1)); // absolute, milliseconds + + // the mode is in the COMMAND NAME here, not in an operand - which is the one thing that makes this + // group's expiry different from SET's, and the only reason Expiration is read rather than written. + // Note the ARGUMENT changes units along with the command, so the two have to be chosen together. + Assert.Equal("*6|$7|HEXPIRE|$1|k|$3|300|$6|FIELDS|$1|1|$2|f1|", exec.Sent[0]); + Assert.Equal("*6|$8|HPEXPIRE|$1|k|$4|1500|$6|FIELDS|$1|1|$2|f1|", exec.Sent[1]); + Assert.Equal("*6|$9|HEXPIREAT|$1|k|$10|4133980800|$6|FIELDS|$1|1|$2|f1|", exec.Sent[2]); + Assert.Equal("*6|$10|HPEXPIREAT|$1|k|$13|4133980800001|$6|FIELDS|$1|1|$2|f1|", exec.Sent[3]); + } + + [Fact] + public async Task ExpireRendersItsConditionBeforeTheFields() + { + var (ctx, exec) = Target("*1\r\n:1\r\n"); + + RedisValue[] fields = ["f1"]; + await ctx.Hashes.Expire("k", fields, TimeSpan.FromSeconds(60), ExpireWhen.GreaterThanCurrentExpiry); + + Assert.Equal("*7|$7|HEXPIRE|$1|k|$2|60|$2|GT|$6|FIELDS|$1|1|$2|f1|", Assert.Single(exec.Sent)); + + // NX/XX/GT/LT make it a conditional write, exactly as for the key-level EXPIRE + Assert.Equal(CommandFlags.CommandRetryWriteChecked, Assert.Single(exec.Flags) & Message.MaskRetryCategory); + } + + [Fact] + public void ExpireRefusesSomethingThatIsNotADeadline() + { + var (ctx, _) = Target(); + RedisValue[] fields = ["f1"]; + + Assert.Throws(() => ctx.Hashes.Expire("k", fields, Expiration.KeepTtl)); + Assert.Throws(() => ctx.Hashes.Expire("k", fields, Expiration.Persist)); + Assert.Throws(() => ctx.Hashes.Expire("k", fields, default)); + } + + [Fact] + public async Task LifetimeQueriesAlwaysUseTheMillisecondCommand() + { + var (ctx, exec) = Target("*1\r\n:1000\r\n"); + + RedisValue[] fields = ["f1", "f2"]; + await ctx.Hashes.GetTimeToLive("k", fields); + await ctx.Hashes.GetExpireDateTime("k", fields); + await ctx.Hashes.Persist("k", fields); + + Assert.Equal( + new[] + { + "*6|$5|HPTTL|$1|k|$6|FIELDS|$1|2|$2|f1|$2|f2|", + "*6|$12|HPEXPIRETIME|$1|k|$6|FIELDS|$1|2|$2|f1|$2|f2|", + "*6|$8|HPERSIST|$1|k|$6|FIELDS|$1|2|$2|f1|$2|f2|", + }, + exec.Sent); + } + + [Fact] + public async Task GetSetExpiryPutsTheExpiryBeforeTheFields() + { + var (ctx, exec) = Target("*1\r\n$2\r\nv1\r\n"); + + await ctx.Hashes.GetSetExpiry("k", "f1"); + await ctx.Hashes.GetSetExpiry("k", "f1", TimeSpan.FromSeconds(60)); + await ctx.Hashes.GetSetExpiry("k", "f1", Expiration.Persist); + + Assert.Equal( + new[] + { + "*5|$6|HGETEX|$1|k|$6|FIELDS|$1|1|$2|f1|", + "*7|$6|HGETEX|$1|k|$2|EX|$2|60|$6|FIELDS|$1|1|$2|f1|", + "*6|$6|HGETEX|$1|k|$7|PERSIST|$6|FIELDS|$1|1|$2|f1|", + }, + exec.Sent); + + // a bare HGETEX is a read; anything that touches the TTL is a write + Assert.Equal(CommandFlags.CommandRetryReadOnly, exec.Flags[0] & Message.MaskRetryCategory); + Assert.All(exec.Flags.GetRange(1, 2), f => Assert.Equal(CommandFlags.CommandRetryWriteLastWins, f & Message.MaskRetryCategory)); + } + + [Fact] + public async Task SingleFieldRepliesAreUnwrappedFromTheirArray() + { + var (ctx, exec) = Target("*1\r\n$2\r\nv1\r\n", "*1\r\n$-1\r\n"); + + // the FIELDS commands always reply with an array, one element per field - so asking for one field + // still gets *1, and the caller still wanted one value + Assert.Equal("v1", await ctx.Hashes.GetDelete("k", "f1")); + Assert.True((await ctx.Hashes.GetDelete("k", "f1")).IsNull); + + Assert.Equal("*5|$7|HGETDEL|$1|k|$6|FIELDS|$1|1|$2|f1|", exec.Sent[0]); + } + + [Fact] + public async Task SetWithExpiryRendersConditionThenExpiryThenFields() + { + var (ctx, exec) = Target(); + + await ctx.Hashes.SetWithExpiry("k", "f1", "v1"); + await ctx.Hashes.SetWithExpiry("k", "f1", "v1", TimeSpan.FromSeconds(60), When.NotExists); + + Assert.Equal( + new[] + { + "*6|$6|HSETEX|$1|k|$6|FIELDS|$1|1|$2|f1|$2|v1|", + + // FNX, not NX: the key-level token means something else here + "*9|$6|HSETEX|$1|k|$3|FNX|$2|EX|$2|60|$6|FIELDS|$1|1|$2|f1|$2|v1|", + }, + exec.Sent); + } + + [Fact] + public async Task SetWithExpiryTakesAWholeFieldSet() + { + var (ctx, exec) = Target(); + + HashEntry[] entries = [new("f1", "v1"), new("f2", "v2")]; + await ctx.Hashes.SetWithExpiry("k", entries, Expiration.KeepTtl, When.Exists); + + Assert.Equal( + "*10|$6|HSETEX|$1|k|$3|FXX|$7|KEEPTTL|$6|FIELDS|$1|2|$2|f1|$2|v1|$2|f2|$2|v2|", + Assert.Single(exec.Sent)); + } + + [Fact] + public void SetWithExpiryRefusesAnExpirationItCannotSpell() + { + var (ctx, _) = Target(); + + Assert.Throws( + () => ctx.Hashes.SetWithExpiry("k", "f", "v", new Expiration(TimeSpan.FromSeconds(30), ExpirationFlags.ExpireIfNotExists))); + } + + [Fact] + public async Task IncrementHasNoDecrementTwin() + { + var (ctx, exec) = Target(":4\r\n", "$3\r\n1.5\r\n"); + + await ctx.Hashes.Increment("k", "f", -6); + await ctx.Hashes.Increment("k", "f", 1.5); + + // the server has no HDECRBY, so a negative amount is the only spelling there has ever been + Assert.Equal( + new[] + { + "*4|$7|HINCRBY|$1|k|$1|f|$2|-6|", + "*4|$12|HINCRBYFLOAT|$1|k|$1|f|$3|1.5|", + }, + exec.Sent); + } + + [Fact] + public async Task ExpireResultsComeBackAsTheirEnum() + { + var (ctx, _) = Target("*3\r\n:1\r\n:0\r\n:-2\r\n"); + + RedisValue[] fields = ["a", "b", "c"]; + var results = await ctx.Hashes.Expire("k", fields, TimeSpan.FromSeconds(60)); + + Assert.Equal( + new[] { ExpireResult.Success, ExpireResult.ConditionNotMet, ExpireResult.NoSuchField }, + results); + } +} From 1d95e311473feb58914beb3a9dd0acfc2fb380ae Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 16:58:39 +0100 Subject: [PATCH 115/360] Keep the hash lease adapters on the copying form ReadLease now returns the sharing ReadOnlyLease, so the singleton handler that serves IDatabase.HashFieldGetLease* has to ask for the copying one explicitly - those signatures say Lease, and a lease the caller may write to must not point at memory anything else can read. Deliberately no further than that. Whether the new surface should hand out ReadOnlyLease instead - which is the shape that matters for caching - belongs with design notes 6.16 and the work that is introducing it, not to a guess from over here. --- src/StackExchange.Redis/Interpolated/RespSurface.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index a0b27d608..c1cc94248 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -387,6 +387,12 @@ public RedisValue Parse(ReadOnlySpan response) } } + /// + /// The copying form, matching rather than : this + /// exists to serve IDatabase.HashFieldGetLease*, whose signatures say . + /// A sharing singleton would be a sibling, which is a decision for + /// whoever finishes design notes 6.16 rather than one to guess at here. + /// private sealed class SingletonLeaseHandler : IRespHandler?> { public Lease? Parse(ReadOnlySpan response) @@ -395,7 +401,9 @@ private sealed class SingletonLeaseHandler : IRespHandler?> reader.MoveNext(); if (reader.IsNull) return null; reader.MoveNext(); - return reader.ReadLease(); +#pragma warning disable CS0618 // the copying form is what this contract needs; see the remarks + return RespReaderExtensions.ReadLease(in reader); +#pragma warning restore CS0618 } } From 4f5df39d8845ad6074e18769731d6763eecb80d4 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:02:04 +0100 Subject: [PATCH 116/360] Queue: interface-based handler lookup, and the Execute rename Both are mechanical and both collide with whatever is in flight in RespSurface.cs, so they are queued together for straight after a merge. The handler-registry idea replaces a typeof chain nobody can be relied on to extend with 'implement the interface, and you are registered', memoized the same way. It needs IRespHandler to lose its covariance first: 'as' honours variance, so IRespHandler would let a string handler answer a request for object. Checked that nothing depends on the variance - the repo builds with it invariant. --- design/interpolated-resp-writer.queue.md | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index f5f81bc35..7ea74555b 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -18,6 +18,32 @@ a line saying why, because "we decided not to" is worth as much as "we did". mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. +- [ ] **Replace the `Inbuilt` type registry with interface-based lookup.** Today it is a chain of + `typeof(T) == typeof(X)` tests that someone must remember to extend. Instead: + + ```csharp + internal static class DefaultHandler + { + internal static readonly IRespHandler? Instance = RespHandlers.Defaults as IRespHandler; + } + ``` + + where `Defaults` is one singleton carrying many explicit `IRespHandler` implementations. + Registration becomes "implement the interface", which cannot fall out of step, and memoization is + unchanged (`static readonly` on a closed generic either way). + + **`IRespHandler` must lose its `out` first.** `as` honours variance, so covariance would + let `as IRespHandler` silently match an `IRespHandler` implementation and return a + string parser. Verified that nothing depends on the variance: the whole repo builds with it invariant. + + Explicit implementation is forced anyway (one `Parse` per `T`, differing only by return type, is not a + legal implicit overload set), which conveniently keeps them off the singleton's public surface. The + named vocabulary (`Value`, `Ok`, `Result`, `ReadOnlyLease`) stays as properties over the same + singleton, preserving the deliberate "named versus merely registered" distinction. + + Collides with in-flight work: `RespHandlers` lives in `RespSurface.cs`. Do it right after a merge, + with the rename above. + - [ ] **Stale-while-revalidate** (§6.15). Soft/hard thresholds on `CachePolicy`, once-only refresh via an interlocked flag on the entry, clearing on failure with backoff. Prerequisites are in: single-flight is the same interlock, and `CachePolicy` already carries the lifetime. Remember the cap for the From 907f27fa46966fd19b49fc7298509b910f0f522d Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:05:12 +0100 Subject: [PATCH 117/360] The set commands SADD, SREM, SISMEMBER/SMISMEMBER, SCARD, SMEMBERS, SMOVE, SPOP, SRANDMEMBER, the three combinations and their STORE and CARD forms. SSCAN stays with the other cursors. The group where the variadic hole does most of the work: SetCombine and SetCombineAndStore each carry a `(first, second)` overload that existed only because building a variadic message used to be work, and both collapse into the one method now that a run of keys is a hole. `LIMIT n` arrives as an IRespArgument rather than a fragment, because a fragment cannot spell it - the keyword is a constant and the count is not, and a hole writes one thing. Zero means "no limit", and no limit means the operand is not there at all. Reusable for the sorted-set cardinality commands next. One deliberate divergence: `Pop(key, 0)` removes nothing and says so without asking. The old surface sends a bare SPOP for a count of zero, which removes ONE - a difference between "pop none" and "pop one" that is discovered in production rather than in review. SER352 is down from 472 unimplemented members to 432. --- .../Interpolated/RespLiterals.cs | 8 + .../Interpolated/RespSurface.Sets.cs | 272 ++++++++++++++++++ .../Interpolated/RespSurface.cs | 12 + .../Interpolated/TransitionalDatabase.Sets.cs | 177 ++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 24 ++ .../RespSurfaceSetsTests.cs | 211 ++++++++++++++ tests/StackExchange.Redis.Tests/SetTests.cs | 38 +-- .../TransitionalSurfaceTests.cs | 11 + 8 files changed, 734 insertions(+), 19 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs diff --git a/src/StackExchange.Redis/Interpolated/RespLiterals.cs b/src/StackExchange.Redis/Interpolated/RespLiterals.cs index 55d47f636..48237ea48 100644 --- a/src/StackExchange.Redis/Interpolated/RespLiterals.cs +++ b/src/StackExchange.Redis/Interpolated/RespLiterals.cs @@ -127,6 +127,14 @@ internal static partial class RespLiterals [Resp] internal static partial RespFragment Fxx { get; } + /// The LIMIT operand; a count follows it. + [Resp] + internal static partial RespFragment Limit { get; } + + /// The APPROX operand of the set-cardinality commands. + [Resp] + internal static partial RespFragment Approx { get; } + /// The NX condition of the hash field-expiry commands. [Resp] internal static partial RespFragment Nx { get; } diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs new file mode 100644 index 000000000..331f4f0bb --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs @@ -0,0 +1,272 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The set-command group: target.Sets.Add(...). + /// + /// + /// The smallest group so far, and the one where the variadic hole does most of the work: nearly every + /// command here takes either a run of members or a run of keys, and the old surface spells each of + /// those as a pair of overloads - one fixed-arity, one array. SSCAN stays with the other + /// cursors. + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespSets + { + private readonly RespContext _context; + + /// Group the set commands of a context. + /// The context to send through. + public RespSets(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The set commands. + public RespSets Sets => new(target.Context); + } + + extension(in RespContext context) + { + /// The set commands. + public RespSets Sets => new(context); + } + + /// SADD. + /// The set command group. + /// The key to write. + /// The member to add. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Add(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => sets.Context.SendAsync( + $"{RedisCommand.SADD}{key}{value}", flags.WithDefaultCategory(RedisCommand.SADD)); + + /// SADD with several members; the reply is how many were new. + /// The set command group. + /// The key to write. + /// The members to add. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Add(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => values.IsEmpty + ? new ValueTask(0L) + : sets.Context.SendAsync( + $"{RedisCommand.SADD}{key}{values}", flags.WithDefaultCategory(RedisCommand.SADD)); + + /// SREM. + /// The set command group. + /// The key to write. + /// The member to remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Remove(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => sets.Context.SendAsync( + $"{RedisCommand.SREM}{key}{value}", flags.WithDefaultCategory(RedisCommand.SREM)); + + /// SREM with several members; the reply is how many were removed. + /// The set command group. + /// The key to write. + /// The members to remove. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Remove(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => values.IsEmpty + ? new ValueTask(0L) + : sets.Context.SendAsync( + $"{RedisCommand.SREM}{key}{values}", flags.WithDefaultCategory(RedisCommand.SREM)); + + /// SISMEMBER. + /// The set command group. + /// The key to read. + /// The member to look for. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Contains(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => sets.Context.SendAsync( + $"{RedisCommand.SISMEMBER}{key}{value}", flags.WithDefaultCategory(RedisCommand.SISMEMBER)); + + /// SMISMEMBER: one answer per member, in order. + /// The set command group. + /// The key to read. + /// The members to look for. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Contains(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => values.IsEmpty + ? new ValueTask(Array.Empty()) + : sets.Context.SendAsync( + $"{RedisCommand.SMISMEMBER}{key}{values}", flags.WithDefaultCategory(RedisCommand.SMISMEMBER)); + + /// SCARD. + /// The set command group. + /// The key to measure. + /// Command flags. +#pragma warning disable RS0026 // the set group's members share names with other groups' extension methods, but not receiver types + public static ValueTask Length(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => sets.Context.SendAsync( + $"{RedisCommand.SCARD}{key}", flags.WithDefaultCategory(RedisCommand.SCARD)); + + /// SMEMBERS. + /// The set command group. + /// The key to read. + /// Command flags. + public static ValueTask Members(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync( + $"{RedisCommand.SMEMBERS}{key}", flags.WithDefaultCategory(RedisCommand.SMEMBERS)); + + /// SMOVE. + /// The set command group. + /// The key to take from. + /// The key to add to. + /// The member to move. + /// Command flags. + public static ValueTask Move(this in RespSets sets, RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync( + $"{RedisCommand.SMOVE}{source}{destination}{value}", flags.WithDefaultCategory(RedisCommand.SMOVE)); + + /// SPOP: remove and return one member, at random. + /// The set command group. + /// The key to write. + /// Command flags. +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Pop(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => sets.Context.SendAsync( + $"{RedisCommand.SPOP}{key}", flags.WithDefaultCategory(RedisCommand.SPOP)); + + /// SPOP with a count. + /// The set command group. + /// The key to write. + /// How many to remove. + /// Command flags. + /// + /// A count of zero removes nothing, and says so without asking - unlike the old surface, which + /// sends a bare SPOP and would remove one. That is a divergence, and a deliberate + /// one: "pop none" quietly popping one is the kind of thing a caller discovers in production. + /// +#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter + public static ValueTask Pop(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) +#pragma warning restore RS0026 + => count == 0 + ? new ValueTask(Array.Empty()) + : sets.Context.SendAsync( + $"{RedisCommand.SPOP}{key}{count}", flags.WithDefaultCategory(RedisCommand.SPOP)); + + /// SRANDMEMBER: one member, at random, left in place. + /// The set command group. + /// The key to read. + /// Command flags. + public static ValueTask RandomMember(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync( + $"{RedisCommand.SRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + + /// SRANDMEMBER with a count. + /// The set command group. + /// The key to read. + /// How many to take; a negative count allows repeats. + /// Command flags. + public static ValueTask RandomMembers(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync( + $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + + /// SUNION/SINTER/SDIFF. + /// The set command group. + /// The operation to apply. + /// The keys to combine. + /// Command flags. + /// + /// One method where the old surface has two: the (first, second) overload existed because + /// building a variadic message used to be work, and with a run of keys as a hole it is the same + /// expression either way. + /// + public static ValueTask Combine(this in RespSets sets, SetOperation operation, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + var command = operation.ToSetCommand(); + return sets.Context.SendAsync($"{command}{keys}", flags.WithDefaultCategory(command)); + } + + /// SUNIONSTORE/SINTERSTORE/SDIFFSTORE; the reply is the destination's size. + /// The set command group. + /// The operation to apply. + /// The key to write the result to. + /// The keys to combine. + /// Command flags. + public static ValueTask CombineAndStore(this in RespSets sets, SetOperation operation, RedisKey destination, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + var command = operation.ToSetStoreCommand(); + return sets.Context.SendAsync($"{command}{destination}{keys}", flags.WithDefaultCategory(command)); + } + + /// SINTERCARD/SUNIONCARD/SDIFFCARD: the size of a combination, without building it. + /// The set command group. + /// The operation to measure. + /// The keys to combine. + /// Stop counting at this many; zero for no limit. + /// Allow an estimate, where the server supports one. + /// Command flags. + /// + /// is deliberately not gated here. Today only SUNIONCARD + /// accepts APPROX and the others will error - but a stale client-side check would block a + /// later server that extended it, so the server decides. Same reasoning as RedisDatabase. + /// + public static ValueTask CombineLength( + this in RespSets sets, + SetOperation operation, + ReadOnlySpan keys, + long limit = 0, + bool approximate = false, + CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + // numkeys comes FIRST here, unlike the plain combinations - the trailing LIMIT/APPROX operands + // are why the server needs to be told where the key list stops + var command = operation.ToSetCardinalityCommand(); + return sets.Context.SendAsync( + $"{command}{keys.Length}{keys}{(approximate ? RespLiterals.Approx : default)}{new RespLimit(limit)}", + flags.WithDefaultCategory(command)); + } + } + + /// + /// EXPERIMENTAL SPIKE. The LIMIT n operand, which writes two arguments or none. + /// + /// + /// A fragment cannot spell this: the keyword is a constant but the count is not, and a hole writes one + /// thing. is the mechanism for exactly that - it writes as many arguments + /// as it likes, including none, which is how an absent optional operand is spelled throughout this + /// surface. Zero means "no limit", and no limit means the operand is simply not there. + /// + internal readonly struct RespLimit(long limit) : IRespArgument + { + /// + public void WriteTo(scoped ref RespCommandHandler handler) + { + if (limit <= 0) return; + + handler.AppendFormatted(RespLiterals.Limit); + handler.AppendFormatted((RedisValue)limit); + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index c1cc94248..eb04aff99 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -138,6 +138,7 @@ internal static IRespHandler Require() else if (typeof(T) == typeof(long[])) handler = s_int64Array; else if (typeof(T) == typeof(ExpireResult[])) handler = s_expireResults; else if (typeof(T) == typeof(PersistResult[])) handler = s_persistResults; + else if (typeof(T) == typeof(bool[])) handler = s_booleans; return (IRespHandler?)handler; } } @@ -298,6 +299,7 @@ private sealed class ReadOnlyLeaseHandler : IRespHandler?> private static readonly IRespHandler s_int64Array = new Int64ArrayHandler(); private static readonly IRespHandler s_expireResults = new ExpireResultHandler(); private static readonly IRespHandler s_persistResults = new PersistResultHandler(); + private static readonly IRespHandler s_booleans = new BooleanArrayHandler(); private sealed class DigestHandler : IRespHandler { @@ -445,6 +447,16 @@ public ExpireResult[] Parse(ReadOnlySpan response) } } + private sealed class BooleanArrayHandler : IRespHandler + { + public bool[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadPastArray(static (ref r) => r.ReadBoolean(), scalar: true) ?? Array.Empty(); + } + } + private sealed class PersistResultHandler : IRespHandler { public PersistResult[] Parse(ReadOnlySpan response) diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs new file mode 100644 index 000000000..3f7f67746 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs @@ -0,0 +1,177 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The set commands, where they have moved to the RESP context surface. + /// + /// + /// The group where the old surface's fixed-arity twins disappear entirely: SetCombine and + /// SetCombineAndStore each have a (first, second) overload that existed only because + /// building a variadic message used to be work. Both now unpack into the one group method. + /// + internal sealed partial class TransitionalDatabase + { + /// + public bool SetAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Add(key, value, flags)); + + /// + public Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Sets.Add(key, value, flags).AsTask(); + + /// + public long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Add(key, Required(values, nameof(values)), flags)); + + /// + public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Context.Sets.Add(key, Required(values, nameof(values)), flags).AsTask(); + + /// + public bool SetRemove(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Remove(key, value, flags)); + + /// + public Task SetRemoveAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Sets.Remove(key, value, flags).AsTask(); + + /// + public long SetRemove(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Remove(key, Required(values, nameof(values)), flags)); + + /// + public Task SetRemoveAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Context.Sets.Remove(key, Required(values, nameof(values)), flags).AsTask(); + + /// + public bool SetContains(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Contains(key, value, flags)); + + /// + public Task SetContainsAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Sets.Contains(key, value, flags).AsTask(); + + /// + public bool[] SetContains(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Contains(key, Required(values, nameof(values)), flags)); + + /// + public Task SetContainsAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => Context.Sets.Contains(key, Required(values, nameof(values)), flags).AsTask(); + + /// + public long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Length(key, flags)); + + /// + public Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Sets.Length(key, flags).AsTask(); + + /// + public RedisValue[] SetMembers(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Members(key, flags)); + + /// + public Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Sets.Members(key, flags).AsTask(); + + /// + public bool SetMove(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Move(source, destination, value, flags)); + + /// + public Task SetMoveAsync(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) + => Context.Sets.Move(source, destination, value, flags).AsTask(); + + /// + public RedisValue SetPop(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Pop(key, flags)); + + /// + public Task SetPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Sets.Pop(key, flags).AsTask(); + + /// + public RedisValue[] SetPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Pop(key, count, flags)); + + /// + public Task SetPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.Sets.Pop(key, count, flags).AsTask(); + + /// + public RedisValue SetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.RandomMember(key, flags)); + + /// + public Task SetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.Sets.RandomMember(key, flags).AsTask(); + + /// + public RedisValue[] SetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.RandomMembers(key, count, flags)); + + /// + public Task SetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.Sets.RandomMembers(key, count, flags).AsTask(); + + // the (first, second) overloads are the old spelling of a two-key run; unpacking them here is the + // whole of the difference, and a null `second` is how that surface says "just the one" + + /// + public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Combine(operation, Pair(first, second), flags)); + + /// + public Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Context.Sets.Combine(operation, Pair(first, second), flags).AsTask(); + + /// + public RedisValue[] SetCombine(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.Combine(operation, Required(keys, nameof(keys)), flags)); + + /// + public Task SetCombineAsync(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Context.Sets.Combine(operation, Required(keys, nameof(keys)), flags).AsTask(); + + /// + public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.CombineAndStore(operation, destination, Pair(first, second), flags)); + + /// + public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => Context.Sets.CombineAndStore(operation, destination, Pair(first, second), flags).AsTask(); + + /// + public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.CombineAndStore(operation, destination, Required(keys, nameof(keys)), flags)); + + /// + public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => Context.Sets.CombineAndStore(operation, destination, Required(keys, nameof(keys)), flags).AsTask(); + + /// + public long SetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.CombineLength(SetOperation.Intersect, Required(keys, nameof(keys)), limit, approximate: false, flags)); + + /// + public Task SetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => Context.Sets.CombineLength(SetOperation.Intersect, Required(keys, nameof(keys)), limit, approximate: false, flags).AsTask(); + + /// + public long SetCombineLength(SetOperation operation, RedisKey[] keys, long limit = 0, bool approximate = false, CommandFlags flags = CommandFlags.None) + => Wait(Context.Sets.CombineLength(operation, Required(keys, nameof(keys)), limit, approximate, flags)); + + /// + public Task SetCombineLengthAsync(SetOperation operation, RedisKey[] keys, long limit = 0, bool approximate = false, CommandFlags flags = CommandFlags.None) + => Context.Sets.CombineLength(operation, Required(keys, nameof(keys)), limit, approximate, flags).AsTask(); + + /// + /// The old (first, second) shape as a run of keys; a default second means one key. + /// + private static RedisKey[] Pair(in RedisKey first, in RedisKey second) + => second.IsNull ? [first] : [first, second]; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 502b58587..91a440068 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -153,6 +153,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespRequest.Span.get -> System.ReadOnlySpan [SER010]StackExchange.Redis.Interpolated.RespRequest.TryGetKeys(scoped System.Span target) -> int [SER010]StackExchange.Redis.Interpolated.RespRequest.TryRetain(out StackExchange.Redis.Interpolated.RespRequest retained) -> bool +[SER010]StackExchange.Redis.Interpolated.RespSets +[SER010]StackExchange.Redis.Interpolated.RespSets.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespSets.RespSets() -> void +[SER010]StackExchange.Redis.Interpolated.RespSets.RespSets(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespStrings.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespStrings.RespStrings() -> void @@ -161,10 +165,12 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Sets.get -> StackExchange.Redis.Interpolated.RespSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Sets.get -> StackExchange.Redis.Interpolated.RespSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool @@ -191,7 +197,14 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Values.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Append(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineAndStore(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineLength(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, long limit = 0, bool approximate = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -228,16 +241,25 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Keys(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequence(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Members(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Move(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey source, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Operation(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.Bitwise operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomField(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMember(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -253,6 +275,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespHashes [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespHashes +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespSets +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespSets [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs new file mode 100644 index 000000000..b01d0562d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// What the set group puts on the wire, byte for byte, against a fake executor. +/// +/// +public class RespSurfaceSetsTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public List Flags { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + Flags.Add(request.Flags); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static (RespContext Context, FakeExecutor Executor) Target(params string[] replies) + { + var executor = new FakeExecutor(replies.Length == 0 ? [":1\r\n"] : replies); + return (new RespContext().WithExecutor(executor), executor); + } + + [Fact] + public async Task OneMemberAndManyAreTheSameCommand() + { + var (ctx, exec) = Target(); + + await ctx.Sets.Add("k", "a"); + await ctx.Sets.Add("k", ["a", "b"]); + + // unlike the string group's Get, the arity is the ONLY difference here - so the two overloads + // exist for their return types (bool versus a count), not for the command + Assert.Equal( + new[] { "*3|$4|SADD|$1|k|$1|a|", "*4|$4|SADD|$1|k|$1|a|$1|b|" }, + exec.Sent); + } + + [Fact] + public async Task MembersAreValuesAndNotKeys() + { + var (ctx, exec) = Target(); + + await ctx.WithKeyPrefix("t:").Sets.Remove("k", ["a", "b"]); + + Assert.Equal("*4|$4|SREM|$3|t:k|$1|a|$1|b|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task NothingToDoMeansNoCommand() + { + var (ctx, exec) = Target(); + + Assert.Equal(0, await ctx.Sets.Add("k", ReadOnlySpan.Empty)); + Assert.Equal(0, await ctx.Sets.Remove("k", ReadOnlySpan.Empty)); + Assert.Empty(await ctx.Sets.Contains("k", ReadOnlySpan.Empty)); + + Assert.Empty(exec.Sent); + } + + [Fact] + public async Task PopOfNoneRemovesNothing() + { + var (ctx, exec) = Target("*2\r\n$1\r\na\r\n$1\r\nb\r\n"); + + // the old surface sends a bare SPOP for a count of zero, which removes ONE. Diverging here is + // deliberate: "pop none" quietly popping one is discovered in production, not in review. + Assert.Empty(await ctx.Sets.Pop("k", 0L)); + Assert.Empty(exec.Sent); + + await ctx.Sets.Pop("k", 2); + Assert.Equal("*3|$4|SPOP|$1|k|$1|2|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task ContainsHasASingularAndAPluralCommand() + { + var (ctx, exec) = Target(":1\r\n", "*2\r\n:1\r\n:0\r\n"); + + Assert.True(await ctx.Sets.Contains("k", "a")); + Assert.Equal(new[] { true, false }, await ctx.Sets.Contains("k", ["a", "b"])); + + Assert.Equal( + new[] { "*3|$9|SISMEMBER|$1|k|$1|a|", "*4|$10|SMISMEMBER|$1|k|$1|a|$1|b|" }, + exec.Sent); + } + + [Fact] + public async Task CombineTakesAnyNumberOfKeys() + { + var (ctx, exec) = Target("*1\r\n$1\r\na\r\n"); + + RedisKey[] keys = ["s1", "s2", "s3"]; + await ctx.Sets.Combine(SetOperation.Union, keys); + await ctx.Sets.Combine(SetOperation.Difference, ["s1"]); + + Assert.Equal( + new[] + { + "*4|$6|SUNION|$2|s1|$2|s2|$2|s3|", + "*2|$5|SDIFF|$2|s1|", + }, + exec.Sent); + } + + [Fact] + public async Task CombineAndStorePutsTheDestinationFirst() + { + var (ctx, exec) = Target(); + + RedisKey[] keys = ["s1", "s2"]; + await ctx.Sets.CombineAndStore(SetOperation.Intersect, "dest", keys); + + Assert.Equal("*4|$11|SINTERSTORE|$4|dest|$2|s1|$2|s2|", Assert.Single(exec.Sent)); + } + + [Fact] + public void CombineChecksItsArityBeforeTheServerDoes() + { + var (ctx, _) = Target(); + + Assert.Throws(() => ctx.Sets.Combine(SetOperation.Union, ReadOnlySpan.Empty)); + Assert.Throws(() => ctx.Sets.CombineAndStore(SetOperation.Union, "d", ReadOnlySpan.Empty)); + Assert.Throws(() => ctx.Sets.CombineLength(SetOperation.Union, ReadOnlySpan.Empty)); + } + + [Fact] + public async Task CombineLengthCountsItsKeysForTheServer() + { + var (ctx, exec) = Target(); + + RedisKey[] keys = ["s1", "s2"]; + await ctx.Sets.CombineLength(SetOperation.Intersect, keys); + await ctx.Sets.CombineLength(SetOperation.Intersect, keys, limit: 10); + await ctx.Sets.CombineLength(SetOperation.Union, keys, limit: 10, approximate: true); + + // numkeys comes FIRST here, unlike the plain combinations - the trailing LIMIT/APPROX operands are + // exactly why the server has to be told where the key list stops + Assert.Equal( + new[] + { + "*4|$10|SINTERCARD|$1|2|$2|s1|$2|s2|", + "*6|$10|SINTERCARD|$1|2|$2|s1|$2|s2|$5|LIMIT|$2|10|", + "*7|$10|SUNIONCARD|$1|2|$2|s1|$2|s2|$6|APPROX|$5|LIMIT|$2|10|", + }, + exec.Sent); + } + + [Fact] + public async Task ALimitOfZeroIsNoLimitAndSoNoOperand() + { + var (ctx, exec) = Target(); + + RedisKey[] keys = ["s1"]; + await ctx.Sets.CombineLength(SetOperation.Intersect, keys, limit: 0); + + // RespLimit writes two arguments or none; this is the "none", and it is why a fragment could not + // have spelled it - the keyword is constant but the count is not + Assert.Equal("*3|$10|SINTERCARD|$1|1|$2|s1|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task MoveNamesBothKeys() + { + var (ctx, exec) = Target(); + + await ctx.WithKeyPrefix("t:").Sets.Move("src", "dst", "m"); + + // both are keys, so both are prefixed; the member is not + Assert.Equal("*4|$5|SMOVE|$5|t:src|$5|t:dst|$1|m|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task ReadsAndWritesLandInTheRightRetryCategories() + { + var (ctx, exec) = Target("*0\r\n", ":1\r\n", "$1\r\na\r\n"); + + await ctx.Sets.Members("k"); + await ctx.Sets.Add("k", "a"); + await ctx.Sets.Pop("k"); + + Assert.Equal(CommandFlags.CommandRetryReadOnly, exec.Flags[0] & Message.MaskRetryCategory); + + // SADD is idempotent - adding a member twice converges - so it is checked, not accumulating + Assert.Equal(CommandFlags.CommandRetryWriteChecked, exec.Flags[1] & Message.MaskRetryCategory); + + // SPOP takes something away on every call, and a replay takes a DIFFERENT member + Assert.Equal(CommandFlags.CommandRetryWriteAccumulating, exec.Flags[2] & Message.MaskRetryCategory); + } +} diff --git a/tests/StackExchange.Redis.Tests/SetTests.cs b/tests/StackExchange.Redis.Tests/SetTests.cs index 23d744220..fc35ba492 100644 --- a/tests/StackExchange.Redis.Tests/SetTests.cs +++ b/tests/StackExchange.Redis.Tests/SetTests.cs @@ -14,7 +14,7 @@ public async Task SetContains() await using var conn = Create(require: RedisFeatures.v6_2_0); var key = Me(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); db.KeyDelete(key); for (int i = 1; i < 1001; i++) { @@ -46,7 +46,7 @@ public async Task SetContainsAsync() await using var conn = Create(require: RedisFeatures.v6_2_0); var key = Me(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); await db.KeyDeleteAsync(key); for (int i = 1; i < 1001; i++) { @@ -77,7 +77,7 @@ public async Task SetIntersectionLength() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; db.KeyDelete(key1, CommandFlags.FireAndForget); @@ -103,7 +103,7 @@ public async Task SetIntersectionLengthAsync() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; db.KeyDelete(key1, CommandFlags.FireAndForget); @@ -129,7 +129,7 @@ public async Task SetCombineLength_Union() { await using var conn = Create(require: RedisFeatures.v8_10_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; db.KeyDelete(key1, CommandFlags.FireAndForget); @@ -160,7 +160,7 @@ public async Task SetCombineLength_Difference() { await using var conn = Create(require: RedisFeatures.v8_10_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; db.KeyDelete(key1, CommandFlags.FireAndForget); @@ -188,7 +188,7 @@ public async Task SetCombineLength_Intersect() { await using var conn = Create(require: RedisFeatures.v8_10_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me() + "1"; db.KeyDelete(key1, CommandFlags.FireAndForget); @@ -210,7 +210,7 @@ public async Task SScan() var server = GetAnyPrimary(conn); var key = Me(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); int totalUnfiltered = 0, totalFiltered = 0; for (int i = 1; i < 1001; i++) { @@ -233,7 +233,7 @@ public async Task SetRemoveArgTests() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); RedisValue[]? values = null; @@ -250,7 +250,7 @@ public async Task SetPopMulti_Multi() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -276,7 +276,7 @@ public async Task SetPopMulti_Single() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -302,7 +302,7 @@ public async Task SetPopMulti_Multi_Async() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -328,7 +328,7 @@ public async Task SetPopMulti_Single_Async() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -354,7 +354,7 @@ public async Task SetPopMulti_Zero_Async() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -376,7 +376,7 @@ public async Task SetAdd_Zero() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -392,7 +392,7 @@ public async Task SetAdd_Zero_Async() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -410,7 +410,7 @@ public async Task SetPopMulti_Nil() { await using var conn = Create(require: RedisFeatures.v3_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -424,7 +424,7 @@ public async Task TestSortReadonlyPrimary() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); @@ -445,7 +445,7 @@ public async Task TestSortReadonlyReplica() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); diff --git a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs index a1b085b96..80bd94b54 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs @@ -82,6 +82,15 @@ protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = - => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); } +/// +[RunPerProtocol] +public class TransitionalSetTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : SetTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} + /// /// That the re-runs above are actually re-running anything. /// @@ -115,6 +124,7 @@ private static string[] Generated(string prefix, Type iface) [Theory] [InlineData("String")] [InlineData("Hash")] + [InlineData("Set")] public void EveryMemberOfAMovedGroupIsImplemented(string prefix) { var generated = Generated(prefix, typeof(IDatabase)).Concat(Generated(prefix, typeof(IDatabaseAsync))) @@ -130,6 +140,7 @@ public void EveryMemberOfAMovedGroupIsImplemented(string prefix) .Where(x => !x.StartsWith("StringGetWithExpiry", StringComparison.Ordinal)) .Where(x => !x.StartsWith("HashImport", StringComparison.Ordinal)) .Where(x => !x.StartsWith("HashScan", StringComparison.Ordinal)) + .Where(x => !x.StartsWith("SetScan", StringComparison.Ordinal)) .ToArray(); Assert.Empty(expected); From 696a5c3f2b7df438fc024e02d7346e963b78cf95 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:08:38 +0100 Subject: [PATCH 118/360] RespResult shares the reply buffer instead of copying it The blit is gone on the path external modules actually use. A cached or freshly-received reply becomes a RespResult by taking a reference, not by renting a second buffer and memcpying into it. Safe here where it would not be for a byte lease, for two reasons: RespResult exposes only RespReader, so nothing can write through it; and sharing a CACHE ENTRY pins nothing extra, because the entry holds that buffer for its own lifetime regardless. Safe and free, which is the case worth having. Three parts: - RespResult had to learn to be a WINDOW. RawSpan was _buffer.GetSpan() - buffer is frame - whereas a cached RespPayload is (lease, offset, length) inside something larger. It now carries offset/length, and Read/ReadScalar slice while still passing the buffer as a reader service, which is what lets a lease taken from the reply reserve against it in turn. - An INTERNAL IRespPayloadHandler, because a ReadOnlySpan cannot carry buffer identity, and deciding when retaining is safe is a privilege the library keeps rather than an option it offers. One type test, in the single place every reply already funnelled through. - A fallback that does not resurrect: TryAddRef is increment-if-nonzero, so losing the race against the final release returns null and we copy. One bug worth recording: the two CACHE-HIT paths called handler.Parse(hit.Span) directly rather than going through that funnel, so the first version shared on a fresh reply and copied on a cache hit - exactly backwards, and invisible to any test that checks bytes rather than reference counts. Now routed through it, and mutation-tested: making the handler copy fails three tests. --- design/interpolated-resp-writer.md | 25 ++++++++ design/interpolated-resp-writer.queue.md | 3 +- .../Interpolated/RespExecutor.cs | 39 +++++++++++- .../Interpolated/RespPayload.cs | 13 +++- .../Interpolated/RespSurface.cs | 15 ++++- src/StackExchange.Redis/RespResult.cs | 51 ++++++++++++++- .../RespResultHandlerTests.cs | 62 +++++++++++++++++++ 7 files changed, 199 insertions(+), 9 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 2ddb835d4..86afebb93 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2298,6 +2298,31 @@ cache hit. Per-type rules do not work, because the *same* type is safe in one to also makes it immune to the streaming case that makes a byte-lease conditional (a chunked scalar has to be assembled to be handed over as bytes; it does not have to be assembled to be *stored*). +#### Built: `RespResult` shares the buffer + +The blit is gone on the path that matters most. `RespResult.Share(buffer, offset, length)` takes a +reference instead of renting and copying, and the handler reaches it through an **internal** +`IRespPayloadHandler` — because a `ReadOnlySpan` cannot carry buffer identity (see below), and +judging when retaining is safe is a privilege the library keeps rather than an option it offers. + +Three things this needed: + +- **`RespResult` had to learn to be a window.** `RawSpan` was `_buffer.GetSpan()` — buffer *is* frame — + whereas a cached `RespPayload` is `(lease, offset, length)` inside a larger one. It now carries the + offset and length, and `Read`/`ReadScalar` slice accordingly while still passing the buffer as a reader + service, which is what lets a lease taken from the reply reserve against it in turn. +- **One dispatch point.** The executor's `Parse` helper tests for the payload-aware shape once; every reply + already funnelled through it. **Except the two cache-hit paths, which called `handler.Parse(hit.Span)` + directly** — so the first version shared on a fresh reply and quietly copied on a cache hit, which is + precisely backwards. Caught by asserting the reference count rather than the bytes. +- **A fallback that does not resurrect.** `TryAddRef` is increment-if-nonzero, so losing the race against + the final release yields `null` and the handler copies. Winning it by any other means would hand back a + buffer already on its way to the pool. + +Why this is safe where a byte lease would not be: `RespResult` exposes only `RespReader`, so nothing can +write through it. And sharing a *cache entry* pins nothing extra — the entry holds that buffer for its own +lifetime regardless — so this is the case where sharing is both safe and free. + #### Decided: share internally, copy on the way out The interpolated surface **always copies what it hands to a caller**. Not a retreat — three reasons, and the diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 7ea74555b..c7e7cd29f 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -119,7 +119,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] `RespResult` as a built-in result type (the NRedisStack path) — `4b4a7434` - [x] `ReadOnlyLease`, and retiring the mutable `ReadLease` spelling — `9a1a37bf` - [x] Ad-hoc `ExecuteAsync` returning `RespResult`, on the context and on `IRespTarget`; `ExecuteResp` - wired through `TransitionalDatabase` — this change + wired through `TransitionalDatabase` — `30d28d70` +- [x] `RespResult` shares the reply buffer instead of copying it — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index bb4daa9bd..658da8a9c 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -62,6 +62,30 @@ public interface IRespHandler TResult Parse(ReadOnlySpan response); } + /// + /// A handler whose result retains the reply's buffer, and therefore needs the payload itself + /// rather than a view of its bytes. + /// + /// What parsing the reply produces. + /// + /// + /// Internal, deliberately. A cannot carry buffer identity, so a + /// handler given one can only ever copy - which is right for the general case and for anything supplied + /// from outside. Retaining the buffer instead is safe only for a result that cannot write through it, + /// and judging that is a privilege the library keeps rather than an option it offers. + /// + /// + /// Implementations must take their own reference; the pipeline releases its own as soon as parsing + /// returns. + /// + /// + internal interface IRespPayloadHandler : IRespHandler + { + /// Parse the reply, optionally retaining its buffer. + /// The reply; take a reference if the result outlives this call. + TResult Parse(RespPayload payload); + } + /// /// EXPERIMENTAL SPIKE. Sending a request, with or without a client-side cache. /// @@ -99,7 +123,16 @@ public static class RespExecutor /// /// private static TResult Parse(IRespHandler handler, RespPayload? response) - => response is null ? default! : handler.Parse(response.Span); + => response switch + { + null => default!, + + // a handler whose result retains the buffer needs the payload, not a view of it; one test, + // in the one place every reply already funnels through + _ when handler is IRespPayloadHandler retaining => retaining.Parse(response), + + _ => handler.Parse(response.Span), + }; /// /// Send a request and parse the reply, optionally serving it from - and populating - the context's cache. @@ -360,7 +393,7 @@ private static bool TryServeFromCache( request.Dispose(); try { - result = handler.Parse(hit.Span); + result = Parse(handler, hit); return true; } finally @@ -443,7 +476,7 @@ private static async ValueTask AwaitShared( { try { - return handler.Parse(hit.Span); + return Parse(handler, hit); } finally { diff --git a/src/StackExchange.Redis/Interpolated/RespPayload.cs b/src/StackExchange.Redis/Interpolated/RespPayload.cs index afbbc0896..7ed90feb3 100644 --- a/src/StackExchange.Redis/Interpolated/RespPayload.cs +++ b/src/StackExchange.Redis/Interpolated/RespPayload.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Diagnostics.CodeAnalysis; using RESPite; @@ -66,6 +66,17 @@ public static RespPayload Create(ReadOnlySpan value) /// The number of live references; zero once the blob is back in the pool. internal int RefCount => _lease.RefCount; + /// + /// This reply as a that shares these bytes rather than copying them. + /// + /// The reply, or null if the buffer had already gone. + /// + /// Internal on purpose. Handing out the buffer identity is how zero-copy is possible at all, and it + /// is only safe because exposes nothing that can write through it - so it + /// is a privilege the library keeps rather than an option callers get. + /// + internal RespResult? ShareAsResult() => RespResult.Share(_lease, _offset, _length); + /// /// Take a reference, so the blob cannot be recycled while it is being read. Returns false if /// it has already gone - treat that as a cache miss. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 5164d1a04..41b9394c9 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -239,8 +239,21 @@ private sealed class StringHandler : IRespHandler /// notes 6.16. /// /// - private sealed class RespResultHandler : IRespHandler + private sealed class RespResultHandler : IRespPayloadHandler { + /// + /// Share the reply's buffer rather than copying it - one more reference, not a second copy. + /// + /// + /// The whole reason RespResult is the general-purpose result type: it exposes only + /// readers, so nothing can write through it, which is what makes sharing memory that is still + /// owned elsewhere safe. Falls back to a copy if the buffer has already gone - losing that race + /// means it is on its way back to the pool, and resurrecting it is exactly what must not happen. + /// + public RespResult Parse(RespPayload payload) + => payload.ShareAsResult() ?? RespResult.Capture(payload.Span); + + /// The copying path, for a caller who only has the bytes. public RespResult Parse(ReadOnlySpan response) => RespResult.Capture(response); } diff --git a/src/StackExchange.Redis/RespResult.cs b/src/StackExchange.Redis/RespResult.cs index 3b178dc8f..3764187d1 100644 --- a/src/StackExchange.Redis/RespResult.cs +++ b/src/StackExchange.Redis/RespResult.cs @@ -41,11 +41,56 @@ private static RespResult CreateNullSingleton(RespPrefix prefix, ReadOnlySpan + /// Wrap an existing buffer without copying, taking a reference to it. + /// + /// The buffer holding the raw frame. + /// Where the frame starts within it. + /// How long the frame is. + /// The reply, or null if the buffer had already gone - treat that as a miss. + /// + /// + /// The zero-copy counterpart of , and safe + /// for a buffer that is still owned elsewhere for a reason no mutable type can offer: everything this + /// type exposes is a RespReader, so nothing can write through it. Sharing a cache entry + /// also pins nothing extra - the cache holds that buffer for the entry's lifetime regardless. See + /// design notes 6.16. + /// + /// + /// The reference is taken here and given back by , so the reply outlives whatever + /// the pipeline does with its own reference the moment parsing returns. + /// + /// + internal static RespResult? Share(RefCountedBuffer buffer, int offset, int length) + { + var probe = new RespReader(buffer.GetSpan().Slice(offset, length)); + probe.MovePastBof(); + + // increment-if-nonzero: losing this race means the buffer is already going back to its pool, which + // the caller must treat as a miss rather than resurrecting it + if (!buffer.TryAddRef()) return null; + + return new RespResult(probe.Prefix, probe.IsNull, buffer, offset, length); } /// @@ -100,7 +145,7 @@ internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReade /// public bool IsNull { get; } - private Span RawSpan => (_buffer ?? ThrowDisposed()).GetSpan(); + private Span RawSpan => (_buffer ?? ThrowDisposed()).GetSpan().Slice(_offset, _length); [DoesNotReturn] private static RefCountedBuffer ThrowDisposed() => throw new ObjectDisposedException(nameof(RespResult)); @@ -112,7 +157,7 @@ internal static RespResult Capture(RespPrefix prefix, bool isNull, ref RespReade public RespReader Read() { var buffer = _buffer ?? ThrowDisposed(); - var reader = new RespReader(buffer.GetSpan(), buffer); + var reader = new RespReader(buffer.GetSpan().Slice(_offset, _length), buffer); reader.MoveNext(); return reader; } @@ -124,7 +169,7 @@ public RespReader Read() public RespReader ReadScalar() { var buffer = _buffer ?? ThrowDisposed(); - var reader = new RespReader(buffer.GetSpan(), buffer); + var reader = new RespReader(buffer.GetSpan().Slice(_offset, _length), buffer); reader.MoveNextScalar(); return reader; } diff --git a/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs b/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs index 8762a5e13..8b620c99e 100644 --- a/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs +++ b/tests/StackExchange.Redis.Tests/RespResultHandlerTests.cs @@ -100,6 +100,68 @@ public async Task ARespResultServedFromCacheIsIndistinguishable() Assert.Equal(1, executor.Sends); // the second was a cache hit } + [Fact] + public async Task ItSharesTheReplyBufferRatherThanCopyingIt() + { + // THE point. A RespResult exposes only readers, so nothing can write through it - which is what + // makes it safe to hand back a view of memory the pipeline (or the cache) still owns. The proof is + // the reference count: sharing takes one, copying would not. + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + Assert.Equal(1, payload.RefCount); + + var handler = (IRespPayloadHandler)RespHandlers.Result; + using var result = handler.Parse(payload); + + Assert.Equal(2, payload.RefCount); // shared, not copied + Assert.Equal("hello", result.ReadScalar().ReadString()); + + // and it survives the pipeline letting go of its own reference, which happens the instant parsing + // returns - a result that had merely borrowed the bytes would be reading a recycled buffer here + payload.Release(); + Assert.Equal(1, payload.RefCount); + Assert.Equal("hello", result.ReadScalar().ReadString()); + } + + [Fact] + public void DisposingTheResultGivesTheReferenceBack() + { + var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); + var handler = (IRespPayloadHandler)RespHandlers.Result; + + var result = handler.Parse(payload); + Assert.Equal(2, payload.RefCount); + + result.Dispose(); + Assert.Equal(1, payload.RefCount); + + payload.Release(); + } + + [Fact] + public async Task ACacheHitSharesTheStoredEntry() + { + // the case the whole exercise is about: a cached reply costs a reference, not a memcpy - and + // sharing a cache entry pins nothing extra, because the entry holds that buffer anyway + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + var context = Context(executor, cache); + + using (var first = await context.SendAsync( + $"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly)) + { + Assert.Equal("hello", first.ReadScalar().ReadString()); + } + + using var second = await context.SendAsync( + $"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly); + + Assert.Equal(1, executor.Sends); // the second was a cache hit + Assert.Equal("hello", second.ReadScalar().ReadString()); + + // the entry and this result both hold the same buffer + Assert.True(second.RefCount >= 2, $"expected a shared reference, saw {second.RefCount}"); + } + [Fact] public async Task TheResultOutlivesTheReplyItCameFrom() { From 13b538a5c52bfdf9256017d477732820d5664f5b Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:13:54 +0100 Subject: [PATCH 119/360] Queue: rule out buffer-sharing into RedisValue It has no disposal, so it can never give a reference back - leaving only pin forever, or let the pool reclaim the buffer while the value still points into it. Not a gap to plug: the absence of a lifetime. RespResult can share precisely because it has one. --- design/interpolated-resp-writer.queue.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index c7e7cd29f..147e10076 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -132,5 +132,11 @@ a line saying why, because "we decided not to" is worth as much as "we did". - **`NOLOOP` on `CLIENT TRACKING`.** In default mode the server stops tracking a key we wrote even when it suppresses the message, so anything whose key set we under-declare (`EVAL` with computed keys) goes *permanently* stale rather than briefly. §6.13. +- **Sharing a buffer into a `RedisValue`.** Tempting, because single-value replies are a large cohort and + they are exactly the ones a cache serves. But `RedisValue` has no disposal, so it can never give a + reference back — which leaves only "pin the buffer forever" or "let the pool reclaim it while the value + still points at it". That is not a gap to be plugged; it is the absence of a lifetime, and `RespResult` + exists because it *has* one. + - **Deriving `BCAST PREFIX` from `WithKeyPrefix`.** Prefixes are connection-global, must not overlap — context prefixes routinely nest — and cannot be removed individually. §6.13. From 57245627986ae1ba362d4cd627eb7724b2839654 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:17:57 +0100 Subject: [PATCH 120/360] Say the RS0026 exemption once, not fifty times Fifty pragma pairs, all with the same justification, were drowning the command declarations they were attached to. One suppression on the partial class, with the reason written out properly: Every member is an extension method whose first parameter is a group type, so two members sharing a name are only candidates for the same call when their receivers are the same group - and within a group the overloads differ in a parameter that has no default. The names repeat across groups on purpose; ctx.Strings.Length and ctx.Sets.Length are the same word because they are the same idea, which is the whole argument for grouping. --- .../Interpolated/RespSurface.Bitmaps.cs | 8 -- .../Interpolated/RespSurface.Hashes.cs | 32 -------- .../Interpolated/RespSurface.Sets.cs | 18 ----- .../Interpolated/RespSurface.Strings.cs | 26 ------ .../Interpolated/RespSurface.cs | 80 +++++++++++++++++++ 5 files changed, 80 insertions(+), 84 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs index 6669e0ea9..4c0942bce 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Bitmaps.cs @@ -55,9 +55,7 @@ public static partial class RespSurface /// The key to read. /// The bit offset. /// Command flags. -#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types public static ValueTask Get(this in RespBitmaps bitmaps, RedisKey key, long offset, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => bitmaps.Context.SendAsync( $"{RedisCommand.GETBIT}{key}{offset}", flags.WithDefaultCategory(RedisCommand.GETBIT)); @@ -67,9 +65,7 @@ public static ValueTask Get(this in RespBitmaps bitmaps, RedisKey key, lon /// The bit offset; the value is zero-extended up to it. /// The bit to set. /// Command flags. -#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types public static ValueTask Set(this in RespBitmaps bitmaps, RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => bitmaps.Context.SendAsync( $"{RedisCommand.SETBIT}{key}{offset}{bit}", flags.WithDefaultCategory(RedisCommand.SETBIT)); @@ -205,7 +201,6 @@ public static ValueTask Operation( /// OVERFLOW FAIL. /// /// -#pragma warning disable RS0026 // the bitmap group's Get/Set/Field share names with other groups' extension methods, but not receiver types public static ValueTask> Field( this in RespBitmaps bitmaps, RedisKey key, @@ -237,7 +232,6 @@ public static ValueTask Operation( var frame = cmd.Complete(); return bitmaps.Context.SendAsync(ref frame, flags, RespHandlers.Inbuilt>.Require()); } -#pragma warning restore RS0026 /// BITFIELD with a single sub-operation, whose reply is one value rather than a run. /// The bitmap command group. @@ -249,9 +243,7 @@ public static ValueTask Operation( /// replies with - so the common case costs neither a lease nor a disposal. /// means the operation was skipped by OVERFLOW FAIL. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the third parameter public static ValueTask Field(this in RespBitmaps bitmaps, RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 { // deliberately NOT a one-element span: BitFieldOperation holds a RedisValue, so it cannot be // stackalloc'd, and the span-from-a-single-value constructor does not exist on every target diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs index 3d119a331..8c61507d9 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -56,9 +56,7 @@ public static partial class RespSurface /// The key to read. /// The field to read. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Get(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HGET}{key}{field}", flags.WithDefaultCategory(RedisCommand.HGET)); @@ -71,9 +69,7 @@ public static ValueTask Get(this in RespHashes hashes, RedisKey key, /// No fields means no command, as elsewhere: an arity-zero HMGET is a server error, and the /// values of no fields is an empty array without asking anyone. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Get(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -85,9 +81,7 @@ public static ValueTask Get(this in RespHashes hashes, RedisKey ke /// The field to read. /// Command flags. /// The lease must be disposed. -#pragma warning disable RS0026 // the hash group's members share names with other groups' extension methods, but not receiver types public static ValueTask?> GetLease(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync?>( $"{RedisCommand.HGET}{key}{field}", flags.WithDefaultCategory(RedisCommand.HGET)); @@ -119,9 +113,7 @@ public static ValueTask Values(this in RespHashes hashes, RedisKey /// The hash command group. /// The key to measure. /// Command flags. -#pragma warning disable RS0026 // the hash group's members share names with other groups' extension methods, but not receiver types public static ValueTask Length(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HLEN}{key}", flags.WithDefaultCategory(RedisCommand.HLEN)); @@ -193,7 +185,6 @@ public static ValueTask RandomFieldsWithValues(this in RespHashes h /// SET: there is no way to store "no value", and an empty string is a different one. /// /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Set( this in RespHashes hashes, RedisKey key, @@ -213,7 +204,6 @@ public static ValueTask Set( return hashes.Context.SendAsync($"{command}{key}{field}{value}", flags.WithDefaultCategory(command)); } -#pragma warning restore RS0026 /// HMSET: set several fields in one command. /// The hash command group. @@ -225,9 +215,7 @@ public static ValueTask Set( /// is nothing to return - but the reply is still read, because a server error is the only thing /// such a call can report. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Set(this in RespHashes hashes, RedisKey key, ReadOnlySpan entries, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => entries.IsEmpty ? default : hashes.Context.SendAsync( @@ -238,9 +226,7 @@ public static ValueTask Set(this in RespHashes hashes, RedisKey key, ReadOnlySpa /// The key to write. /// The field to remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Delete(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HDEL}{key}{field}", flags.WithDefaultCategory(RedisCommand.HDEL)); @@ -249,9 +235,7 @@ public static ValueTask Delete(this in RespHashes hashes, RedisKey key, Re /// The key to write. /// The fields to remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Delete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => fields.IsEmpty ? new ValueTask(0L) : hashes.Context.SendAsync( @@ -268,9 +252,7 @@ public static ValueTask Delete(this in RespHashes hashes, RedisKey key, Re /// : the server has no /// HDECRBY, and the old surface's HashDecrement is already a negation. /// -#pragma warning disable RS0026 // long/double are disambiguated by the amount's type public static ValueTask Increment(this in RespHashes hashes, RedisKey key, RedisValue field, long value = 1, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HINCRBY}{key}{field}{value}", flags.WithDefaultCategory(RedisCommand.HINCRBY)); @@ -280,9 +262,7 @@ public static ValueTask Increment(this in RespHashes hashes, RedisKey key, /// The field to increment. /// The amount to add. /// Command flags. -#pragma warning disable RS0026 // long/double are disambiguated by the amount's type public static ValueTask Increment(this in RespHashes hashes, RedisKey key, RedisValue field, double value, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HINCRBYFLOAT}{key}{field}{value}", flags.WithDefaultCategory(RedisCommand.HINCRBYFLOAT)); @@ -373,9 +353,7 @@ public static ValueTask GetExpireDateTime(this in RespHashes hashes, Red /// The key to write. /// The field to read and remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask GetDelete(this in RespHashes hashes, RedisKey key, RedisValue field, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HGETDEL}{key}{RespLiterals.Fields}{1}{field}", flags.WithDefaultCategory(RedisCommand.HGETDEL), @@ -386,9 +364,7 @@ public static ValueTask GetDelete(this in RespHashes hashes, RedisKe /// The key to write. /// The fields to read and remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask GetDelete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -417,9 +393,7 @@ public static ValueTask GetDelete(this in RespHashes hashes, Redis /// /// Command flags. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask GetSetExpiry(this in RespHashes hashes, RedisKey key, RedisValue field, Expiration expiry = default, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => hashes.Context.SendAsync( $"{RedisCommand.HGETEX}{key}{expiry}{RespLiterals.Fields}{1}{field}", WithGetExCategory(expiry, flags), @@ -431,9 +405,7 @@ public static ValueTask GetSetExpiry(this in RespHashes hashes, Redi /// The fields to read. /// The expiration to apply. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask GetSetExpiry(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -476,7 +448,6 @@ public static ValueTask GetSetExpiry(this in RespHashes hashes, Re /// is. /// /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask SetWithExpiry( this in RespHashes hashes, RedisKey key, @@ -485,7 +456,6 @@ public static ValueTask SetWithExpiry( Expiration expiry = default, When when = When.Always, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 { expiry.GetTokenCount(allowEnx: false); // HSETEX has no ENX; say so here rather than on the wire return hashes.Context.SendAsync( @@ -501,7 +471,6 @@ public static ValueTask SetWithExpiry( /// Whether the fields must already exist, or must not. /// Command flags. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask SetWithExpiry( this in RespHashes hashes, RedisKey key, @@ -509,7 +478,6 @@ public static ValueTask SetWithExpiry( Expiration expiry = default, When when = When.Always, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 { if (entries.IsEmpty) return new ValueTask(false); diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs index 331f4f0bb..a2e77a217 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs @@ -46,9 +46,7 @@ public static partial class RespSurface /// The key to write. /// The member to add. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Add(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => sets.Context.SendAsync( $"{RedisCommand.SADD}{key}{value}", flags.WithDefaultCategory(RedisCommand.SADD)); @@ -57,9 +55,7 @@ public static ValueTask Add(this in RespSets sets, RedisKey key, RedisValu /// The key to write. /// The members to add. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Add(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => values.IsEmpty ? new ValueTask(0L) : sets.Context.SendAsync( @@ -70,9 +66,7 @@ public static ValueTask Add(this in RespSets sets, RedisKey key, ReadOnlyS /// The key to write. /// The member to remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Remove(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => sets.Context.SendAsync( $"{RedisCommand.SREM}{key}{value}", flags.WithDefaultCategory(RedisCommand.SREM)); @@ -81,9 +75,7 @@ public static ValueTask Remove(this in RespSets sets, RedisKey key, RedisV /// The key to write. /// The members to remove. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Remove(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => values.IsEmpty ? new ValueTask(0L) : sets.Context.SendAsync( @@ -94,9 +86,7 @@ public static ValueTask Remove(this in RespSets sets, RedisKey key, ReadOn /// The key to read. /// The member to look for. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Contains(this in RespSets sets, RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => sets.Context.SendAsync( $"{RedisCommand.SISMEMBER}{key}{value}", flags.WithDefaultCategory(RedisCommand.SISMEMBER)); @@ -105,9 +95,7 @@ public static ValueTask Contains(this in RespSets sets, RedisKey key, Redi /// The key to read. /// The members to look for. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Contains(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => values.IsEmpty ? new ValueTask(Array.Empty()) : sets.Context.SendAsync( @@ -117,9 +105,7 @@ public static ValueTask Contains(this in RespSets sets, RedisKey key, Re /// The set command group. /// The key to measure. /// Command flags. -#pragma warning disable RS0026 // the set group's members share names with other groups' extension methods, but not receiver types public static ValueTask Length(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => sets.Context.SendAsync( $"{RedisCommand.SCARD}{key}", flags.WithDefaultCategory(RedisCommand.SCARD)); @@ -145,9 +131,7 @@ public static ValueTask Move(this in RespSets sets, RedisKey source, Redis /// The set command group. /// The key to write. /// Command flags. -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Pop(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => sets.Context.SendAsync( $"{RedisCommand.SPOP}{key}", flags.WithDefaultCategory(RedisCommand.SPOP)); @@ -161,9 +145,7 @@ public static ValueTask Pop(this in RespSets sets, RedisKey key, Com /// sends a bare SPOP and would remove one. That is a divergence, and a deliberate /// one: "pop none" quietly popping one is the kind of thing a caller discovers in production. /// -#pragma warning disable RS0026 // the one/many overloads are disambiguated by the second parameter public static ValueTask Pop(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => count == 0 ? new ValueTask(Array.Empty()) : sets.Context.SendAsync( diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 0413c11ca..588bcafa4 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -94,9 +94,7 @@ public static ValueTask ExecuteAsync( /// The string command group. /// The key to read. /// Command flags. -#pragma warning disable RS0026 // the key/keys overloads are disambiguated by the first parameter public static ValueTask Get(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.GET}{key}", flags.WithDefaultCategory(RedisCommand.GET)); @@ -116,9 +114,7 @@ public static ValueTask Get(this in RespStrings strings, RedisKey ke /// synchronously and allocates nothing. /// /// -#pragma warning disable RS0026 // the key/keys overloads are disambiguated by the first parameter public static ValueTask Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => keys.IsEmpty ? new ValueTask(Array.Empty()) : strings.Context.SendAsync( @@ -133,9 +129,7 @@ public static ValueTask Get(this in RespStrings strings, ReadOnlyS /// overload: the two differ only in return type, and C# does not overload on that. The lease must /// be disposed. /// -#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask?> GetLease(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync?>( $"{RedisCommand.GET}{key}", flags.WithDefaultCategory(RedisCommand.GET)); @@ -153,9 +147,7 @@ public static ValueTask GetRange(this in RespStrings strings, RedisK /// The string command group. /// The key to read and remove. /// Command flags. -#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask GetDelete(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.GETDEL}{key}", flags.WithDefaultCategory(RedisCommand.GETDEL)); @@ -181,9 +173,7 @@ public static ValueTask GetDelete(this in RespStrings strings, Redis /// render into a command the server will reject. /// /// -#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask GetSetExpiry(this in RespStrings strings, RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 { var mutatesTtl = expiry.GetTokenCount(allowEnx: false) != 0; if (mutatesTtl) flags = flags.WithRetryCategory(CommandFlags.CommandRetryWriteLastWins); @@ -196,9 +186,7 @@ public static ValueTask GetSetExpiry(this in RespStrings strings, Re /// The string command group. /// The key to measure. /// Command flags. -#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask Length(this in RespStrings strings, RedisKey key, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.STRLEN}{key}", flags.WithDefaultCategory(RedisCommand.STRLEN)); @@ -297,7 +285,6 @@ public static ValueTask SetRange(this in RespStrings strings, RedisKey key /// delete and are dropped, which is also what the old builder does. /// /// -#pragma warning disable RS0026 // the single-key and multi-key overloads are disambiguated by the second parameter public static ValueTask Set( this in RespStrings strings, RedisKey key, @@ -311,7 +298,6 @@ public static ValueTask Set( $"{RedisCommand.SET}{key}{value}{when}{expiry}", flags.WithRetryCategory(when.RetryCategory) .WithDefaultCategory(RedisCommand.SET)); -#pragma warning restore RS0026 /// MSET/MSETNX/MSETEX: set several keys in one command. /// The string command group. @@ -337,7 +323,6 @@ public static ValueTask Set( /// writing nothing succeeded. /// /// -#pragma warning disable RS0026 // the single-key and multi-key overloads are disambiguated by the second parameter public static ValueTask Set( this in RespStrings strings, ReadOnlySpan> values, @@ -368,7 +353,6 @@ or ValueCondition.ConditionKind.Exists ? strings.Context.SendAsync($"{command}{values.Length}{values}{expiry}{when}", flags) : strings.Context.SendAsync($"{command}{values}", flags); } -#pragma warning restore RS0026 /// SET ... GET: write the value, and reply with the one it replaced. /// The string command group. @@ -427,7 +411,6 @@ public static ValueTask SetAndGet( /// would delete the key the caller was protecting. /// /// -#pragma warning disable RS0026 // the string group's members share names with other groups' extension methods, but not receiver types public static ValueTask Delete( this in RespStrings strings, RedisKey key, @@ -453,7 +436,6 @@ public static ValueTask Delete( return ThrowUnsupportedCondition>(when, nameof(Delete)); } } -#pragma warning restore RS0026 /// INCRBY, and INCRBYFLOAT for the floating-point twin. /// The string command group. @@ -473,9 +455,7 @@ public static ValueTask Delete( /// branch on every call. /// /// -#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry public static ValueTask Increment(this in RespStrings strings, RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.INCRBY}{key}{value}", flags.WithDefaultCategory(RedisCommand.INCRBY)); @@ -484,9 +464,7 @@ public static ValueTask Increment(this in RespStrings strings, RedisKey ke /// The key to increment. /// The amount to add. /// Command flags. -#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry public static ValueTask Increment(this in RespStrings strings, RedisKey key, double value, CommandFlags flags = CommandFlags.None) -#pragma warning restore RS0026 => strings.Context.SendAsync( $"{RedisCommand.INCRBYFLOAT}{key}{value}", flags.WithDefaultCategory(RedisCommand.INCRBYFLOAT)); @@ -512,7 +490,6 @@ public static ValueTask Increment(this in RespStrings strings, RedisKey /// a command the server refuses. /// /// -#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry public static ValueTask> Increment( this in RespStrings strings, RedisKey key, @@ -542,7 +519,6 @@ public static ValueTask> Increment( var frame = cmd.Complete(); return strings.Context.SendAsync(ref frame, flags.WithDefaultCategory(RedisCommand.INCREX), RespHandlers.Inbuilt>.Require()); } -#pragma warning restore RS0026 /// /// The string command group. @@ -553,7 +529,6 @@ public static ValueTask> Increment( /// The highest value the result may take, if any. /// Whether a bound clamps the result or rejects the increment. /// Command flags. -#pragma warning disable RS0026 // long/double, and INCRBY/INCREX, are disambiguated by the amount's type and by the required expiry public static ValueTask> Increment( this in RespStrings strings, RedisKey key, @@ -583,7 +558,6 @@ public static ValueTask> Increment( var frame = cmd.Complete(); return strings.Context.SendAsync(ref frame, flags.WithDefaultCategory(RedisCommand.INCREX), RespHandlers.Inbuilt>.Require()); } -#pragma warning restore RS0026 /// LCS: the longest common subsequence of two keys' values. /// The string command group. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index eb04aff99..62c4e802c 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -139,6 +139,11 @@ internal static IRespHandler Require() else if (typeof(T) == typeof(ExpireResult[])) handler = s_expireResults; else if (typeof(T) == typeof(PersistResult[])) handler = s_persistResults; else if (typeof(T) == typeof(bool[])) handler = s_booleans; + else if (typeof(T) == typeof(double?)) handler = s_nullableDouble; + else if (typeof(T) == typeof(double?[])) handler = s_nullableDoubles; + else if (typeof(T) == typeof(SortedSetEntry[])) handler = s_sortedSetEntries; + else if (typeof(T) == typeof(SortedSetEntry?)) handler = s_sortedSetEntry; + else if (typeof(T) == typeof(SortedSetPopResult)) handler = s_sortedSetPop; return (IRespHandler?)handler; } } @@ -300,6 +305,11 @@ private sealed class ReadOnlyLeaseHandler : IRespHandler?> private static readonly IRespHandler s_expireResults = new ExpireResultHandler(); private static readonly IRespHandler s_persistResults = new PersistResultHandler(); private static readonly IRespHandler s_booleans = new BooleanArrayHandler(); + private static readonly IRespHandler s_nullableDouble = new NullableDoubleHandler(); + private static readonly IRespHandler s_nullableDoubles = new NullableDoubleArrayHandler(); + private static readonly IRespHandler s_sortedSetEntries = new SortedSetEntryArrayHandler(); + private static readonly IRespHandler s_sortedSetEntry = new SortedSetEntryHandler(); + private static readonly IRespHandler s_sortedSetPop = new SortedSetPopHandler(); private sealed class DigestHandler : IRespHandler { @@ -447,6 +457,66 @@ public ExpireResult[] Parse(ReadOnlySpan response) } } + private sealed class NullableDoubleHandler : IRespHandler + { + public double? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.IsNull ? null : reader.ReadDouble(); + } + } + + private sealed class NullableDoubleArrayHandler : IRespHandler + { + public double?[] Parse(ReadOnlySpan response) + { + // ZMSCORE replies nil for a member that is not there, so the element type has to be nullable + var reader = new RespReader(response); + reader.MoveNext(); + return reader.ReadPastArray(static (ref r) => r.IsNull ? (double?)null : r.ReadDouble(), scalar: true) + ?? Array.Empty(); + } + } + + private sealed class SortedSetEntryArrayHandler : IRespHandler + { + // as HashEntryHandler: interleaved in RESP2, possibly jagged in RESP3, decided from the content + private static readonly ResultProcessor.SortedSetEntryArrayProcessor Shape = new(); + + public SortedSetEntry[] Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return Shape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) + ?? Array.Empty(); + } + } + + private sealed class SortedSetEntryHandler : IRespHandler + { + public SortedSetEntry? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return SortedSetEntry.TryRead(ref reader, out var result) + ? result + : throw new RespException("Unexpected sorted-set pop reply."); + } + } + + private sealed class SortedSetPopHandler : IRespHandler + { + public SortedSetPopResult Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return SortedSetPopResult.TryRead(ref reader, out var result) + ? result + : throw new RespException("Unexpected ZMPOP reply."); + } + } + private sealed class BooleanArrayHandler : IRespHandler { public bool[] Parse(ReadOnlySpan response) @@ -501,6 +571,16 @@ public StringIncrementResult Parse(ReadOnlySpan response) /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + + // RS0026 warns about overloads that carry optional parameters, because adding one later can make an + // existing call ambiguous. That hazard cannot arise here, and saying so once beats a pragma per + // command: every member of this class is an extension method whose FIRST parameter is a group type - + // RespStrings, RespHashes, RespSets, ... - so two members sharing a name are only ever candidates for + // the same call when their receivers are the same group, and within a group the overloads differ in a + // parameter that has no default (a span versus a single value, a long versus a double). The names + // repeat across groups on purpose: ctx.Strings.Length and ctx.Sets.Length are the same word because + // they are the same idea, which is the entire argument for grouping. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple overloads with optional parameters", Justification = "Extension members on distinct group types; see the comment above")] public static partial class RespSurface { } From 4a7014989fe7b0a4cf8f6af40ef5ade47fbcb28b Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:17:57 +0100 Subject: [PATCH 121/360] The sorted-set commands The library's biggest group, and the one with the most formatting the wire cares about and the caller does not. All of it is shared with the MessageWriter path rather than restated - GetRange for a score bound's '(' prefix, GetLexRange for '['/'-'/'+', ReverseLimits for putting a range into the direction its command walks. A second copy of a bound convention is a silent off-by-one-bound waiting to happen, and this is the group where that would be hardest to notice. Where the overloads collapse: SortedSetAdd has six (three condition arities across single and multiple members), SortedSetUpdate is the same command with CH, and SortedSetIncrement/SortedSetDecrement are the same command again with INCR and a sign. Three group methods take all of them - `change` and the condition are parameters, because on the wire that is exactly what they are. New spellings of things the surface already knew how to say: SortedSetEntry implements IRespArgument, so a whole run of members is one hole - writing score then element, which is the reverse of how the type reads and the one entry type whose wire order is not its declaration order. RespSortedSetOptions carries ZADD's six option tokens AND its retry category, because both depend on the same flags: NX/XX/GT/LT converge on replay, but INCR compounds unless NX makes a replay a no-op. Two bugs the re-run of SortedSetTests found, both worth the telling: **`default(RespLimitRange)` is (0, 0), not "no limit".** Written where "no window" was meant, it rendered `LIMIT 0 0` and quietly returned an empty range - 36 tests, all of them about ranges. The sentinel is now a named `None`, because a zero value that means something else is exactly the kind of thing that reads as correct. **`Aggregate` has a fourth member.** Sum/Min/Max looked like the whole enum; Count is real, and the switch threw for it. SER352 is down from 432 unimplemented members to 354. --- .../APITypes/SortedSetEntry.Resp.cs | 72 ++ .../APITypes/SortedSetEntry.cs | 2 +- .../APITypes/SortedSetPopResult.Resp.cs | 41 + .../APITypes/SortedSetPopResult.cs | 2 +- .../Interpolated/RespLiterals.cs | 50 + .../Interpolated/RespSurface.SortedSets.cs | 914 ++++++++++++++++++ .../TransitionalDatabase.SortedSets.cs | 375 +++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 37 + src/StackExchange.Redis/RedisDatabase.cs | 13 +- src/StackExchange.Redis/ResultProcessor.cs | 70 +- .../RespSurfaceSortedSetsTests.cs | 354 +++++++ .../SortedSetTests.cs | 124 +-- .../TransitionalSurfaceTests.cs | 13 +- 13 files changed, 1940 insertions(+), 127 deletions(-) create mode 100644 src/StackExchange.Redis/APITypes/SortedSetEntry.Resp.cs create mode 100644 src/StackExchange.Redis/APITypes/SortedSetPopResult.Resp.cs create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs create mode 100644 src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs diff --git a/src/StackExchange.Redis/APITypes/SortedSetEntry.Resp.cs b/src/StackExchange.Redis/APITypes/SortedSetEntry.Resp.cs new file mode 100644 index 000000000..f58ade7bd --- /dev/null +++ b/src/StackExchange.Redis/APITypes/SortedSetEntry.Resp.cs @@ -0,0 +1,72 @@ +using RESPite.Messages; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +public readonly partial struct SortedSetEntry : Interpolated.IRespArgument +{ + /// + /// + /// + /// Two arguments, score then element - the order ZADD wants them in, and the reverse of + /// how the type reads. Worth stating out loud: this is the one entry type whose wire order is not its + /// declaration order, and a hole that emitted them the other way round would produce a command the + /// server accepts and misinterprets. + /// + /// + /// Explicit, as on and for the same reason; reached only through a command + /// hole, where a whole run of entries is one {values}. + /// + /// + void Interpolated.IRespArgument.WriteTo(scoped ref Interpolated.RespCommandHandler handler) + { + handler.AppendFormatted((RedisValue)score); + handler.AppendFormatted(element); + } + + /// + /// Read a [element, score] pair, as ZPOPMIN/ZPOPMAX reply with. + /// + /// The reader, positioned on the aggregate. + /// The entry, or for an empty or null reply. + /// + /// Shared by both readers - the ResultProcessor path and the interpolated surface's handler - + /// so the two cannot disagree about the same bytes. See for the + /// same arrangement. + /// + internal static bool TryRead(ref RespReader reader, out SortedSetEntry? result) + { + result = null; + if (!reader.IsAggregate) return false; + + // Note: null arrays report false for TryMoveNext, so no explicit null check needed + if (reader.TryMoveNext() && reader.IsScalar) + { + var element = reader.ReadRedisValue(); + if (reader.TryMoveNext() && reader.IsScalar) + { + var score = reader.TryReadDouble(out var val) ? val : double.NaN; + result = new SortedSetEntry(element, score); + } + } + + return true; + } + + /// Read one [element, score] pair from within a run of them. + /// The reader, positioned on the pair. + internal static SortedSetEntry ReadPair(ref RespReader reader) + { + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) + { + var element = reader.ReadRedisValue(); + if (reader.TryMoveNext() && reader.IsScalar) + { + var score = reader.TryReadDouble(out var val) ? val : double.NaN; + return new SortedSetEntry(element, score); + } + } + + return default; + } +} diff --git a/src/StackExchange.Redis/APITypes/SortedSetEntry.cs b/src/StackExchange.Redis/APITypes/SortedSetEntry.cs index e61dc05ed..2d57b4e15 100644 --- a/src/StackExchange.Redis/APITypes/SortedSetEntry.cs +++ b/src/StackExchange.Redis/APITypes/SortedSetEntry.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis; /// /// Describes a sorted-set element with the corresponding value. /// -public readonly struct SortedSetEntry : IEquatable, IComparable, IComparable +public readonly partial struct SortedSetEntry : IEquatable, IComparable, IComparable { internal readonly RedisValue element; internal readonly double score; diff --git a/src/StackExchange.Redis/APITypes/SortedSetPopResult.Resp.cs b/src/StackExchange.Redis/APITypes/SortedSetPopResult.Resp.cs new file mode 100644 index 000000000..0ccb5a8a6 --- /dev/null +++ b/src/StackExchange.Redis/APITypes/SortedSetPopResult.Resp.cs @@ -0,0 +1,41 @@ +using RESPite.Messages; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +public readonly partial struct SortedSetPopResult +{ + /// + /// Read a ZMPOP reply: [key, [[element, score], ...]], or nil when no key had anything. + /// + /// The reader, positioned on the reply. + /// The parsed result, when this returns . + /// + /// Shared by both readers - the ResultProcessor path and the interpolated surface's handler - + /// so neither can drift; see for the same arrangement. + /// + internal static bool TryRead(ref RespReader reader, out SortedSetPopResult result) + { + result = Null; + if (!reader.IsAggregate) return false; + + // RESP3 pure null, or a RESP2 null array: nothing was popped, which is not a failure + if (reader.IsNull) return true; + + if (reader.TryMoveNext() && reader.IsScalar) + { + var key = reader.ReadRedisKey(); + if (reader.TryMoveNext() && reader.IsAggregate) + { + var entries = reader.ReadPastArray( + static (ref r) => SortedSetEntry.ReadPair(ref r), + scalar: false); + + result = new SortedSetPopResult(key, entries!); + return true; + } + } + + return false; + } +} diff --git a/src/StackExchange.Redis/APITypes/SortedSetPopResult.cs b/src/StackExchange.Redis/APITypes/SortedSetPopResult.cs index dcdc4c01e..09e1a41be 100644 --- a/src/StackExchange.Redis/APITypes/SortedSetPopResult.cs +++ b/src/StackExchange.Redis/APITypes/SortedSetPopResult.cs @@ -5,7 +5,7 @@ namespace StackExchange.Redis; /// /// A contiguous portion of a redis sorted set. /// -public readonly struct SortedSetPopResult +public readonly partial struct SortedSetPopResult { /// /// A null SortedSetPopResult, indicating no results. diff --git a/src/StackExchange.Redis/Interpolated/RespLiterals.cs b/src/StackExchange.Redis/Interpolated/RespLiterals.cs index 48237ea48..afd7fe2bd 100644 --- a/src/StackExchange.Redis/Interpolated/RespLiterals.cs +++ b/src/StackExchange.Redis/Interpolated/RespLiterals.cs @@ -135,6 +135,56 @@ internal static partial class RespLiterals [Resp] internal static partial RespFragment Approx { get; } + /// The WITHSCORES operand of the sorted-set reads. + [Resp] + internal static partial RespFragment WithScores { get; } + + /// The CH operand of ZADD: count changed members, not just new ones. + [Resp] + internal static partial RespFragment Ch { get; } + + /// The INCR operand of ZADD. + [Resp] + internal static partial RespFragment Incr { get; } + + /// The REV operand of ZRANGE/ZRANGESTORE. + [Resp] + internal static partial RespFragment Rev { get; } + + /// The WEIGHTS operand of the sorted-set combinations; one weight per key follows. + [Resp] + internal static partial RespFragment Weights { get; } + + /// + /// The AGGREGATE operand of the sorted-set combinations; a mode follows it. + /// + /// + /// Separate from the mode rather than one two-token fragment, because an attribute taking an array + /// is not CLS-compliant and this assembly is. RespAggregate writes the pair. + /// + [Resp] + internal static partial RespFragment Aggregate { get; } + + /// The MIN end, as ZMPOP names it. + [Resp] + internal static partial RespFragment Min { get; } + + /// + [Resp] + internal static partial RespFragment Max { get; } + + /// The BYSCORE operand of ZRANGESTORE. + [Resp] + internal static partial RespFragment ByScore { get; } + + /// The BYLEX operand of ZRANGESTORE. + [Resp] + internal static partial RespFragment ByLex { get; } + + /// The COUNT operand of ZMPOP, and the aggregation mode of the same name. + [Resp] + internal static partial RespFragment Count { get; } + /// The NX condition of the hash field-expiry commands. [Resp] internal static partial RespFragment Nx { get; } diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs new file mode 100644 index 000000000..acdff7f12 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs @@ -0,0 +1,914 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The sorted-set command group: target.SortedSets.Add(...). + /// + /// + /// + /// The largest group in the library, and the one with the most formatting the wire cares about and the + /// caller does not: exclusive bounds carry a (, lexical bounds a [, a descending range + /// swaps its own limits, and ZADD has six independent option tokens. All of that is shared with + /// the MessageWriter path rather than restated - a second copy of a bound convention is a silent + /// off-by-one-bound waiting to happen. + /// + /// + /// ZSCAN stays with the other cursors. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespSortedSets + { + private readonly RespContext _context; + + /// Group the sorted-set commands of a context. + /// The context to send through. + public RespSortedSets(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The sorted-set commands. + public RespSortedSets SortedSets => new(target.Context); + } + + extension(in RespContext context) + { + /// The sorted-set commands. + public RespSortedSets SortedSets => new(context); + } + + // ---- membership -------------------------------------------------------------------------------- + + /// ZADD. + /// The sorted-set command group. + /// The key to write. + /// The member to add. + /// The score to give it. + /// The condition the write is subject to. + /// Count members whose score changed, not only members that were new. + /// Command flags. + /// + /// change is what the old surface spells as a separate SortedSetUpdate method; it is + /// one token on the wire (CH) and changes what the reply counts, which is a parameter rather + /// than a command. + /// + public static ValueTask Add( + this in RespSortedSets sortedSets, + RedisKey key, + RedisValue member, + double score, + SortedSetWhen when = SortedSetWhen.Always, + bool change = false, + CommandFlags flags = CommandFlags.None) + { + var options = new RespSortedSetOptions(when, change, increment: false); + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZADD}{key}{options}{score}{member}", + flags.WithRetryCategory(options.RetryCategory).WithDefaultCategory(RedisCommand.ZADD)); + } + + /// ZADD with several members; the reply is how many were added (or changed, under CH). + /// The sorted-set command group. + /// The key to write. + /// The members and their scores. + /// The condition the write is subject to. + /// Count members whose score changed, not only members that were new. + /// Command flags. + /// + /// Each entry writes score then element, which is the reverse of how a + /// reads; the type owns that ordering, so the whole run is one hole. + /// + public static ValueTask Add( + this in RespSortedSets sortedSets, + RedisKey key, + ReadOnlySpan entries, + SortedSetWhen when = SortedSetWhen.Always, + bool change = false, + CommandFlags flags = CommandFlags.None) + { + if (entries.IsEmpty) return new ValueTask(0L); + + var options = new RespSortedSetOptions(when, change, increment: false); + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZADD}{key}{options}{entries}", + flags.WithRetryCategory(options.RetryCategory).WithDefaultCategory(RedisCommand.ZADD)); + } + + /// ZADD ... INCR, or ZINCRBY when there is no condition to carry. + /// The sorted-set command group. + /// The key to write. + /// The member to increment. + /// The amount to add. + /// The condition the increment is subject to. + /// Command flags. + /// + /// + /// when the condition refused the increment - which is why this reports + /// double? where the old surface's unconditional overload reports double. An + /// unconditional increment cannot fail that way, so its caller can safely take the value. + /// + /// + /// ZINCRBY is emitted only for the unconditional case, where it is the shorter spelling of + /// exactly the same request; anything with a condition needs ZADD ... INCR, which is the + /// only form that has one. + /// + /// + public static ValueTask Increment( + this in RespSortedSets sortedSets, + RedisKey key, + RedisValue member, + double value, + SortedSetWhen when = SortedSetWhen.Always, + CommandFlags flags = CommandFlags.None) + { + if (when == SortedSetWhen.Always) + { + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZINCRBY}{key}{value}{member}", flags.WithDefaultCategory(RedisCommand.ZINCRBY)); + } + + var options = new RespSortedSetOptions(when, change: false, increment: true); + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZADD}{key}{options}{value}{member}", + flags.WithRetryCategory(options.RetryCategory).WithDefaultCategory(RedisCommand.ZADD)); + } + + /// ZREM. + /// The sorted-set command group. + /// The key to write. + /// The member to remove. + /// Command flags. + public static ValueTask Remove(this in RespSortedSets sortedSets, RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZREM}{key}{member}", flags.WithDefaultCategory(RedisCommand.ZREM)); + + /// ZREM with several members; the reply is how many were removed. + /// The sorted-set command group. + /// The key to write. + /// The members to remove. + /// Command flags. + public static ValueTask Remove(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) + => members.IsEmpty + ? new ValueTask(0L) + : sortedSets.Context.SendAsync( + $"{RedisCommand.ZREM}{key}{members}", flags.WithDefaultCategory(RedisCommand.ZREM)); + + // ---- simple reads ------------------------------------------------------------------------------ + + /// ZSCORE. + /// The sorted-set command group. + /// The key to read. + /// The member to look up. + /// Command flags. + public static ValueTask Score(this in RespSortedSets sortedSets, RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZSCORE}{key}{member}", flags.WithDefaultCategory(RedisCommand.ZSCORE)); + + /// ZMSCORE: one score per member, in order; nil for a member that is not there. + /// The sorted-set command group. + /// The key to read. + /// The members to look up. + /// Command flags. + public static ValueTask Scores(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) + => members.IsEmpty + ? new ValueTask(Array.Empty()) + : sortedSets.Context.SendAsync( + $"{RedisCommand.ZMSCORE}{key}{members}", flags.WithDefaultCategory(RedisCommand.ZMSCORE)); + + /// ZCARD, or ZCOUNT when a score range is given. + /// The sorted-set command group. + /// The key to measure. + /// The lowest score to count. + /// The highest score to count. + /// Which bounds are exclusive. + /// Command flags. + /// + /// An unbounded range is the whole set, which ZCARD answers without the server having to + /// walk anything - so the default arguments pick a different command, exactly as the old surface + /// does. + /// + public static ValueTask Length( + this in RespSortedSets sortedSets, + RedisKey key, + double min = double.NegativeInfinity, + double max = double.PositiveInfinity, + Exclude exclude = Exclude.None, + CommandFlags flags = CommandFlags.None) + { + if (double.IsNegativeInfinity(min) && double.IsPositiveInfinity(max)) + { + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZCARD}{key}", flags.WithDefaultCategory(RedisCommand.ZCARD)); + } + + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZCOUNT}{key}{RedisDatabase.GetRange(min, exclude, isStart: true)}{RedisDatabase.GetRange(max, exclude, isStart: false)}", + flags.WithDefaultCategory(RedisCommand.ZCOUNT)); + } + + /// ZLEXCOUNT: how many members fall in a lexical range. + /// The sorted-set command group. + /// The key to measure. + /// The lowest member to count. + /// The highest member to count. + /// Which bounds are exclusive. + /// Command flags. + public static ValueTask LengthByValue( + this in RespSortedSets sortedSets, + RedisKey key, + RedisValue min, + RedisValue max, + Exclude exclude = Exclude.None, + CommandFlags flags = CommandFlags.None) + { + RedisDatabase.ReverseLimits(Order.Ascending, ref exclude, ref min, ref max); + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZLEXCOUNT}{key}{Lex(min, exclude, isStart: true)}{Lex(max, exclude, isStart: false)}", + flags.WithDefaultCategory(RedisCommand.ZLEXCOUNT)); + } + + /// ZRANK/ZREVRANK; when the member is not there. + /// The sorted-set command group. + /// The key to read. + /// The member to locate. + /// Which end to count from. + /// Command flags. + public static ValueTask Rank(this in RespSortedSets sortedSets, RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANK : RedisCommand.ZRANK; + return sortedSets.Context.SendAsync( + $"{command}{key}{member}", flags.WithDefaultCategory(command)); + } + + /// ZRANDMEMBER. + /// The sorted-set command group. + /// The key to read. + /// Command flags. + public static ValueTask RandomMember(this in RespSortedSets sortedSets, RedisKey key, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + + /// ZRANDMEMBER with a count. + /// The sorted-set command group. + /// The key to read. + /// How many to take; a negative count allows repeats. + /// Command flags. + public static ValueTask RandomMembers(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + + /// ZRANDMEMBER ... WITHSCORES. + /// The sorted-set command group. + /// The key to read. + /// How many to take; a negative count allows repeats. + /// Command flags. + public static ValueTask RandomMembersWithScores(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANDMEMBER}{key}{count}{RespLiterals.WithScores}", + flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + + // ---- ranges ------------------------------------------------------------------------------------ + + /// ZRANGE/ZREVRANGE by rank. + /// The sorted-set command group. + /// The key to read. + /// The first rank to take. + /// The last rank to take. + /// Which end to count from. + /// Command flags. + public static ValueTask RangeByRank( + this in RespSortedSets sortedSets, + RedisKey key, + long start = 0, + long stop = -1, + Order order = Order.Ascending, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGE : RedisCommand.ZRANGE; + return sortedSets.Context.SendAsync( + $"{command}{key}{start}{stop}", flags.WithDefaultCategory(command)); + } + + /// + /// The sorted-set command group. + /// The key to read. + /// The first rank to take. + /// The last rank to take. + /// Which end to count from. + /// Command flags. + public static ValueTask RangeByRankWithScores( + this in RespSortedSets sortedSets, + RedisKey key, + long start = 0, + long stop = -1, + Order order = Order.Ascending, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGE : RedisCommand.ZRANGE; + return sortedSets.Context.SendAsync( + $"{command}{key}{start}{stop}{RespLiterals.WithScores}", flags.WithDefaultCategory(command)); + } + + /// ZRANGEBYSCORE/ZREVRANGEBYSCORE. + /// The sorted-set command group. + /// The key to read. + /// The lowest score to take. + /// The highest score to take. + /// Which bounds are exclusive. + /// Which end to read from. + /// How many to discard from the front. + /// How many to return; -1 for all. + /// Command flags. + /// + /// The bounds are swapped when the caller's order and their numeric order disagree, and the + /// exclusivity swaps with them - the server always wants low-then-high, whichever direction it is + /// asked to walk. That is the old builder's rule, kept exactly, because a caller who passed + /// (10, 1) descending has always meant the same thing. + /// + public static ValueTask RangeByScore( + this in RespSortedSets sortedSets, + RedisKey key, + double start = double.NegativeInfinity, + double stop = double.PositiveInfinity, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + => RangeByScoreCore(in sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags); + + /// + /// The sorted-set command group. + /// The key to read. + /// The lowest score to take. + /// The highest score to take. + /// Which bounds are exclusive. + /// Which end to read from. + /// How many to discard from the front. + /// How many to return; -1 for all. + /// Command flags. + public static ValueTask RangeByScoreWithScores( + this in RespSortedSets sortedSets, + RedisKey key, + double start = double.NegativeInfinity, + double stop = double.PositiveInfinity, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + => RangeByScoreCore(in sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags); + + /// ZRANGEBYLEX/ZREVRANGEBYLEX. + /// The sorted-set command group. + /// The key to read. + /// The lowest member to take. + /// The highest member to take. + /// Which bounds are exclusive. + /// Which end to read from. + /// How many to discard from the front. + /// How many to return; -1 for all. + /// Command flags. + /// + /// As with the score form, the range is put into low-then-high order first; for a lexical range + /// the open bounds then flip too, which is why - and + are chosen by the order + /// rather than by the position. + /// + public static ValueTask RangeByValue( + this in RespSortedSets sortedSets, + RedisKey key, + RedisValue min = default, + RedisValue max = default, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGEBYLEX : RedisCommand.ZRANGEBYLEX; + + // the bounds stay in start-then-stop order even for the reversed command; what reverses is + // which of them is "low", and GetLexRange's order-aware -/+ mapping is where that lives + RedisDatabase.ReverseLimits(order, ref exclude, ref min, ref max); + + return sortedSets.Context.SendAsync( + $"{command}{key}{Lex(min, exclude, isStart: true, order)}{Lex(max, exclude, isStart: false, order)}{new RespLimitRange(skip, take)}", + flags.WithDefaultCategory(command)); + } + + /// ZRANGESTORE; the reply is the destination's size. + /// The sorted-set command group. + /// The key to read. + /// The key to write the result to. + /// The first bound. + /// The second bound. + /// Whether the bounds are ranks, scores or members. + /// Which bounds are exclusive. + /// Which end to read from. + /// How many to discard from the front. + /// How many to store; for all. + /// Command flags. + /// + /// By rank, neither nor means anything and both + /// are rejected - the server has no operand for either in that mode, so silently dropping them + /// would store a different range than was asked for. + /// + public static ValueTask RangeAndStore( + this in RespSortedSets sortedSets, + RedisKey sourceKey, + RedisKey destinationKey, + RedisValue start, + RedisValue stop, + SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long? take = null, + CommandFlags flags = CommandFlags.None) + { + var category = flags.WithDefaultCategory(RedisCommand.ZRANGESTORE); + var rev = order == Order.Descending ? RespLiterals.Rev : default; // a zero-argument fragment + + if (sortedSetOrder == SortedSetOrder.ByRank) + { + if (take > 0) + { + throw new ArgumentException( + "take argument is not valid when sortedSetOrder is ByRank you may want to try setting the SortedSetOrder to ByLex or ByScore", + nameof(take)); + } + + if (exclude != Exclude.None) + { + throw new ArgumentException( + "exclude argument is not valid when sortedSetOrder is ByRank, you may want to try setting the sortedSetOrder to ByLex or ByScore", + nameof(exclude)); + } + + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANGESTORE}{destinationKey}{sourceKey}{start}{stop}{rev}", category); + } + + // ZRANGESTORE brackets a LEXICAL bound that is merely inclusive, where the read commands leave + // it bare; that asymmetry is the server's, and RespRangeStoreBound is where it is written down + var from = RespRangeStoreBound.Start(start, exclude, sortedSetOrder); + var to = RespRangeStoreBound.Stop(stop, exclude, sortedSetOrder); + var by = sortedSetOrder == SortedSetOrder.ByLex ? RespLiterals.ByLex : RespLiterals.ByScore; + var limit = take is > 0 ? new RespLimitRange(skip, take.GetValueOrDefault()) : RespLimitRange.None; + + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZRANGESTORE}{destinationKey}{sourceKey}{from}{to}{by}{rev}{limit}", category); + } + + // ---- removal by range -------------------------------------------------------------------------- + + /// ZREMRANGEBYRANK. + /// The sorted-set command group. + /// The key to write. + /// The first rank to remove. + /// The last rank to remove. + /// Command flags. + public static ValueTask RemoveRangeByRank(this in RespSortedSets sortedSets, RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZREMRANGEBYRANK}{key}{start}{stop}", flags.WithDefaultCategory(RedisCommand.ZREMRANGEBYRANK)); + + /// ZREMRANGEBYSCORE. + /// The sorted-set command group. + /// The key to write. + /// The lowest score to remove. + /// The highest score to remove. + /// Which bounds are exclusive. + /// Command flags. + public static ValueTask RemoveRangeByScore(this in RespSortedSets sortedSets, RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync( + $"{RedisCommand.ZREMRANGEBYSCORE}{key}{RedisDatabase.GetRange(start, exclude, isStart: true)}{RedisDatabase.GetRange(stop, exclude, isStart: false)}", + flags.WithDefaultCategory(RedisCommand.ZREMRANGEBYSCORE)); + + /// ZREMRANGEBYLEX. + /// The sorted-set command group. + /// The key to write. + /// The lowest member to remove. + /// The highest member to remove. + /// Which bounds are exclusive. + /// Command flags. + public static ValueTask RemoveRangeByValue(this in RespSortedSets sortedSets, RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + { + RedisDatabase.ReverseLimits(Order.Ascending, ref exclude, ref min, ref max); + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZREMRANGEBYLEX}{key}{Lex(min, exclude, isStart: true)}{Lex(max, exclude, isStart: false)}", + flags.WithDefaultCategory(RedisCommand.ZREMRANGEBYLEX)); + } + + // ---- combinations ------------------------------------------------------------------------------ + + /// ZUNION/ZINTER/ZDIFF. + /// The sorted-set command group. + /// The operation to apply. + /// The keys to combine. + /// A multiplier per key, or for all ones. + /// How to fold the scores of a member present in several keys. + /// Command flags. + public static ValueTask Combine( + this in RespSortedSets sortedSets, + SetOperation operation, + ReadOnlySpan keys, + ReadOnlySpan weights = default, + Aggregate aggregate = Aggregate.Sum, + CommandFlags flags = CommandFlags.None) + { + var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); + return CombineCore(in sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags); + } + + /// + /// The sorted-set command group. + /// The operation to apply. + /// The keys to combine. + /// A multiplier per key, or for all ones. + /// How to fold the scores of a member present in several keys. + /// Command flags. + public static ValueTask CombineWithScores( + this in RespSortedSets sortedSets, + SetOperation operation, + ReadOnlySpan keys, + ReadOnlySpan weights = default, + Aggregate aggregate = Aggregate.Sum, + CommandFlags flags = CommandFlags.None) + { + var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); + return CombineCore(in sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags); + } + + /// ZUNIONSTORE/ZINTERSTORE/ZDIFFSTORE; the reply is the destination's size. + /// The sorted-set command group. + /// The operation to apply. + /// The key to write the result to. + /// The keys to combine. + /// A multiplier per key, or for all ones. + /// How to fold the scores of a member present in several keys. + /// Command flags. + public static ValueTask CombineAndStore( + this in RespSortedSets sortedSets, + SetOperation operation, + RedisKey destination, + ReadOnlySpan keys, + ReadOnlySpan weights = default, + Aggregate aggregate = Aggregate.Sum, + CommandFlags flags = CommandFlags.None) + { + var command = ValidateCombine(operation.ToSortedSetStoreCommand(), keys, weights, aggregate); + return CombineCore(in sortedSets, command, destination, keys, weights, aggregate, withScores: false, flags); + } + + /// ZINTERCARD: the size of an intersection, without building it. + /// The sorted-set command group. + /// The keys to intersect. + /// Stop counting at this many; zero for no limit. + /// Command flags. + public static ValueTask CombineLength(this in RespSortedSets sortedSets, ReadOnlySpan keys, long limit = 0, CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZINTERCARD}{keys.Length}{keys}{new RespLimit(limit)}", + flags.WithDefaultCategory(RedisCommand.ZINTERCARD)); + } + + // ---- pops -------------------------------------------------------------------------------------- + + /// ZPOPMIN/ZPOPMAX. + /// The sorted-set command group. + /// The key to write. + /// Which end to take from. + /// Command flags. + public static ValueTask Pop(this in RespSortedSets sortedSets, RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZPOPMAX : RedisCommand.ZPOPMIN; + return sortedSets.Context.SendAsync($"{command}{key}", flags.WithDefaultCategory(command)); + } + + /// ZPOPMIN/ZPOPMAX with a count. + /// The sorted-set command group. + /// The key to write. + /// How many to take. + /// Which end to take from. + /// Command flags. + public static ValueTask Pop(this in RespSortedSets sortedSets, RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + { + // unlike SPOP, a count of zero here is well defined on the wire - but sending it is a round + // trip to be told nothing, which the old surface also declines to make + if (count == 0) return new ValueTask(Array.Empty()); + + var command = order == Order.Descending ? RedisCommand.ZPOPMAX : RedisCommand.ZPOPMIN; + return sortedSets.Context.SendAsync($"{command}{key}{count}", flags.WithDefaultCategory(command)); + } + + /// ZMPOP: take from the first of several keys that has anything. + /// The sorted-set command group. + /// The keys to try, in order. + /// How many to take from whichever key answers. + /// Which end to take from. + /// Command flags. + public static ValueTask Pop(this in RespSortedSets sortedSets, ReadOnlySpan keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentOutOfRangeException(nameof(keys), "keys must have a size of at least 1"); + + var end = order == Order.Descending ? RespLiterals.Max : RespLiterals.Min; + return sortedSets.Context.SendAsync( + $"{RedisCommand.ZMPOP}{keys.Length}{keys}{end}{RespLiterals.Count}{count}", + flags.WithDefaultCategory(RedisCommand.ZMPOP)); + } + + // ---- shared ------------------------------------------------------------------------------------- + + /// ZRANGEBYSCORE and its with-scores twin, which differ only in one token and the result. + private static ValueTask RangeByScoreCore( + in RespSortedSets sortedSets, + RedisKey key, + double start, + double stop, + Exclude exclude, + Order order, + long skip, + long take, + bool withScores, + CommandFlags flags) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGEBYSCORE : RedisCommand.ZRANGEBYSCORE; + + // the server always wants low-then-high, whichever direction it walks; a caller who wrote the + // bounds in their reading order gets them swapped here, exclusivity included + if ((order == Order.Ascending) == (start > stop)) + { + (start, stop) = (stop, start); + exclude = exclude switch + { + Exclude.Start => Exclude.Stop, + Exclude.Stop => Exclude.Start, + _ => exclude, + }; + } + + var from = RedisDatabase.GetRange(start, exclude, isStart: true); + var to = RedisDatabase.GetRange(stop, exclude, isStart: false); + var scores = withScores ? RespLiterals.WithScores : default; + + return sortedSets.Context.SendAsync( + $"{command}{key}{from}{to}{scores}{new RespLimitRange(skip, take)}", + flags.WithDefaultCategory(command)); + } + + /// The checks the combination commands share, and the command they resolve to. + private static RedisCommand ValidateCombine(RedisCommand command, ReadOnlySpan keys, ReadOnlySpan weights, Aggregate aggregate) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + if ((command is RedisCommand.ZDIFF or RedisCommand.ZDIFFSTORE) && (!weights.IsEmpty || aggregate != Aggregate.Sum)) + { + throw new ArgumentException($"{command} cannot be used with weights or aggregation."); + } + + if (!weights.IsEmpty && keys.Length != weights.Length) + { + throw new ArgumentException("Keys and weights should have the same number of elements.", nameof(weights)); + } + + return command; + } + + /// + /// The body every combination shares: numkeys, the keys, the optional weights and aggregation, and + /// for the STORE forms a destination in front. + /// + /// + /// Composed rather than interpolated, and for a reason the other variadic commands do not + /// have: the weights are a run of double, and a run is only a hole when it is a span of + /// something the handler knows. A ref struct cannot implement on + /// every target this library builds for, and copying the doubles into a RedisValue[] just to + /// make them a hole would allocate on a path that has no other reason to. So this is what Compose + /// is for - the same answer BITFIELD reached for a different reason. + /// + private static ValueTask CombineCore( + in RespSortedSets sortedSets, + RedisCommand command, + RedisKey destination, + ReadOnlySpan keys, + ReadOnlySpan weights, + Aggregate aggregate, + bool withScores, + CommandFlags flags) + { + var argHint = 2 + keys.Length + (weights.IsEmpty ? 0 : 1 + weights.Length) + 3; + var cmd = sortedSets.Context.Compose(command, argHint); + try + { + if (!destination.IsNull) cmd.AppendFormatted(destination); + cmd.AppendFormatted((RedisValue)keys.Length); + cmd.AppendFormatted(keys); + + if (!weights.IsEmpty) + { + cmd.AppendFormatted(RespLiterals.Weights); + foreach (var weight in weights) + { + cmd.AppendFormatted((RedisValue)weight); + } + } + + cmd.AppendFormatted(AsFragment(aggregate)); + if (withScores) cmd.AppendFormatted(RespLiterals.WithScores); + } + catch + { + cmd.Dispose(); + throw; + } + + var frame = cmd.Complete(); + return sortedSets.Context.SendAsync(ref frame, flags.WithDefaultCategory(command), RespHandlers.Inbuilt.Require()); + } + + /// A lexical bound, shared with the MessageWriter path; see RedisDatabase.GetLexRange. + private static RedisValue Lex(in RedisValue value, Exclude exclude, bool isStart, Order order = Order.Ascending) + => RedisDatabase.GetLexRange(value, exclude, isStart, order); + + /// The AGGREGATE mode pair; SUM is the server's default and writes nothing. + private static RespAggregate AsFragment(Aggregate aggregate) => new(aggregate); + } + + /// + /// EXPERIMENTAL SPIKE. The option tokens of ZADD: up to six of them, in the order the server + /// documents. + /// + /// + /// The retry category belongs here too, because it depends on the same flags: NX/XX are conditional + /// and GT/LT are monotone, so a replay converges - but INCR compounds on every call unless NX + /// makes a replay a no-op. That is SortedSetAddMessage.GetRetryCategory's rule, kept with the + /// tokens it belongs to rather than at each of the three call sites. + /// + internal readonly struct RespSortedSetOptions : IRespArgument + { + private const SortedSetWhen Known = + SortedSetWhen.Exists | SortedSetWhen.GreaterThan | SortedSetWhen.LessThan | SortedSetWhen.NotExists; + + private readonly SortedSetWhen _when; + private readonly bool _change; + private readonly bool _increment; + + internal RespSortedSetOptions(SortedSetWhen when, bool change, bool increment) + { + if ((when & ~Known) != 0) throw new ArgumentOutOfRangeException(nameof(when)); + + _when = when; + _change = change; + _increment = increment; + } + + /// + /// ZADD covers three very different side-effect profiles depending on its options, so the + /// per-command default (last-wins) is only right for the plain form. + /// + internal CommandFlags RetryCategory + { + get + { + if (!_increment) + { + // NX/XX are conditional; GT/LT are monotone, so re-applying the same score converges. + // A bare ZADD is an unconditional overwrite: leave the per-command default alone. + return _when == SortedSetWhen.Always ? CommandFlags.None : CommandFlags.CommandRetryWriteChecked; + } + + // ZADD ... INCR compounds on every call - *unless* NX, where a replay can only find the + // member present and no-op. + return (_when & SortedSetWhen.NotExists) != 0 + ? CommandFlags.CommandRetryWriteChecked + : CommandFlags.CommandRetryWriteAccumulating; + } + } + + /// + public void WriteTo(scoped ref RespCommandHandler handler) + { + if ((_when & SortedSetWhen.NotExists) != 0) handler.AppendFormatted(RespLiterals.Nx); + if ((_when & SortedSetWhen.Exists) != 0) handler.AppendFormatted(RespLiterals.Xx); + if ((_when & SortedSetWhen.GreaterThan) != 0) handler.AppendFormatted(RespLiterals.Gt); + if ((_when & SortedSetWhen.LessThan) != 0) handler.AppendFormatted(RespLiterals.Lt); + if (_change) handler.AppendFormatted(RespLiterals.Ch); + if (_increment) handler.AppendFormatted(RespLiterals.Incr); + } + } + + /// + /// EXPERIMENTAL SPIKE. A ZRANGESTORE bound, which brackets an inclusive LEXICAL bound where the + /// read commands leave it bare. + /// + /// + /// The asymmetry is the server's rather than ours, and it is exactly the kind of thing that gets + /// "tidied up" into a bug - so it lives in one place and is named for what it is. + /// + internal readonly struct RespRangeStoreBound : IRespArgument + { + private readonly RedisValue _value; + private readonly bool _exclusive; + private readonly bool _lex; + + private RespRangeStoreBound(in RedisValue value, bool exclusive, bool lex) + { + _value = value; + _exclusive = exclusive; + _lex = lex; + } + + /// The lower bound of a ZRANGESTORE range. + internal static RespRangeStoreBound Start(in RedisValue value, Exclude exclude, SortedSetOrder order) + => new(value, (exclude & Exclude.Start) != 0, order == SortedSetOrder.ByLex); + + /// The upper bound of a ZRANGESTORE range. + internal static RespRangeStoreBound Stop(in RedisValue value, Exclude exclude, SortedSetOrder order) + => new(value, (exclude & Exclude.Stop) != 0, order == SortedSetOrder.ByLex); + + /// + public void WriteTo(scoped ref RespCommandHandler handler) + { + if (_exclusive) handler.AppendFormatted(("(" + _value).AsRedisValue()); + else if (_lex) handler.AppendFormatted(("[" + _value).AsRedisValue()); + else handler.AppendFormatted(_value); + } + } + + /// + /// EXPERIMENTAL SPIKE. The AGGREGATE mode pair, which writes two arguments or none. + /// + /// + /// SUM is the server's own default, so it renders as nothing at all - the same arrangement as + /// BYTE on BITCOUNT and KEEPTTL's absence on SET. + /// + internal readonly struct RespAggregate(Aggregate aggregate) : IRespArgument + { + /// + public void WriteTo(scoped ref RespCommandHandler handler) + { + switch (aggregate) + { + case Aggregate.Sum: + return; + case Aggregate.Min: + handler.AppendFormatted(RespLiterals.Aggregate); + handler.AppendFormatted(RespLiterals.Min); + return; + case Aggregate.Max: + handler.AppendFormatted(RespLiterals.Aggregate); + handler.AppendFormatted(RespLiterals.Max); + return; + case Aggregate.Count: + handler.AppendFormatted(RespLiterals.Aggregate); + handler.AppendFormatted(RespLiterals.Count); + return; + default: + throw new ArgumentOutOfRangeException(nameof(aggregate)); + } + } + } + + /// + /// EXPERIMENTAL SPIKE. The LIMIT offset count triple, which writes three arguments or none. + /// + /// + /// (0, -1) is "everything", which is the server's own behaviour without the operand - so the + /// defaults write nothing at all, and only a caller who asked for a window pays for one. + /// + internal readonly struct RespLimitRange(long skip, long take) : IRespArgument + { + /// + /// No window at all - which is (0, -1), not default. + /// + /// + /// The sentinel is not the zero value, and that is worth a name rather than a literal: a + /// default instance is (0, 0), which is a perfectly meaningful window of nothing, and + /// writing default where "no window" was meant renders LIMIT 0 0 and quietly returns + /// an empty range. Found exactly that way. + /// + internal static RespLimitRange None => new(0, -1); + + /// + public void WriteTo(scoped ref RespCommandHandler handler) + { + if (skip == 0 && take == -1) return; // the server's own behaviour without the operand + + handler.AppendFormatted(RespLiterals.Limit); + handler.AppendFormatted((RedisValue)skip); + handler.AppendFormatted((RedisValue)take); + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs new file mode 100644 index 000000000..3c4bb34d8 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs @@ -0,0 +1,375 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// The sorted-set commands, where they have moved to the RESP context surface. + /// + /// + /// + /// The biggest adapter file, and mostly for one reason: SortedSetAdd alone has six overloads + /// (three arities of condition across single and multiple members), SortedSetUpdate is the same + /// command with CH, and SortedSetIncrement/SortedSetDecrement are the same command + /// again with INCR and a sign. All of them land on three group methods. + /// + /// + /// ZSCAN stays in TransitionalDatabase.Scans.cs. + /// + /// + internal sealed partial class TransitionalDatabase + { + // ---- membership -------------------------------------------------------------------------------- + // `When` converts to SortedSetWhen, and the CommandFlags-only overloads are the pre-`When` shapes; + // both are pure adapters with no second opinion about semantics + + /// + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, CommandFlags flags) + => SortedSetAdd(key, member, score, SortedSetWhen.Always, flags); + + /// + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, CommandFlags flags) + => SortedSetAddAsync(key, member, score, SortedSetWhen.Always, flags); + + /// + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) + => SortedSetAdd(key, member, score, SortedSetWhenExtensions.Parse(when), flags); + + /// + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) + => SortedSetAddAsync(key, member, score, SortedSetWhenExtensions.Parse(when), flags); + + /// + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Add(key, member, score, when, change: false, flags)); + + /// + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Add(key, member, score, when, change: false, flags).AsTask(); + + /// + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, CommandFlags flags) + => SortedSetAdd(key, values, SortedSetWhen.Always, flags); + + /// + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, CommandFlags flags) + => SortedSetAddAsync(key, values, SortedSetWhen.Always, flags); + + /// + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) + => SortedSetAdd(key, values, SortedSetWhenExtensions.Parse(when), flags); + + /// + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) + => SortedSetAddAsync(key, values, SortedSetWhenExtensions.Parse(when), flags); + + /// + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Add(key, Required(values, nameof(values)), when, change: false, flags)); + + /// + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Add(key, Required(values, nameof(values)), when, change: false, flags).AsTask(); + + // SortedSetUpdate is SortedSetAdd with CH - the same command, counting changed members rather than + // new ones, which is a parameter on the group method + + /// + public bool SortedSetUpdate(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Add(key, member, score, when, change: true, flags)); + + /// + public Task SortedSetUpdateAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Add(key, member, score, when, change: true, flags).AsTask(); + + /// + public long SortedSetUpdate(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Add(key, Required(values, nameof(values)), when, change: true, flags)); + + /// + public Task SortedSetUpdateAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Add(key, Required(values, nameof(values)), when, change: true, flags).AsTask(); + + // an unconditional increment cannot be refused, so the old signature's non-nullable double is safe; + // the conditional overload is the one that reports null, and it already says so + + /// + public double SortedSetIncrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Increment(key, member, value, SortedSetWhen.Always, flags)).GetValueOrDefault(); + + /// + public async Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => (await Context.SortedSets.Increment(key, member, value, SortedSetWhen.Always, flags).ConfigureAwait(false)).GetValueOrDefault(); + + /// + public double? SortedSetIncrement(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) + => Wait(Context.SortedSets.Increment(key, member, value, AsSortedSetWhen(when), flags)); + + /// + public Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) + => Context.SortedSets.Increment(key, member, value, AsSortedSetWhen(when), flags).AsTask(); + + /// + public double SortedSetDecrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => SortedSetIncrement(key, member, -value, flags); + + /// + public Task SortedSetDecrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => SortedSetIncrementAsync(key, member, -value, flags); + + /// + public bool SortedSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Remove(key, member, flags)); + + /// + public Task SortedSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Remove(key, member, flags).AsTask(); + + /// + public long SortedSetRemove(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Remove(key, Required(members, nameof(members)), flags)); + + /// + public Task SortedSetRemoveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Remove(key, Required(members, nameof(members)), flags).AsTask(); + + // ---- simple reads ------------------------------------------------------------------------------ + + /// + public double? SortedSetScore(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Score(key, member, flags)); + + /// + public Task SortedSetScoreAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Score(key, member, flags).AsTask(); + + /// + public double?[] SortedSetScores(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Scores(key, Required(members, nameof(members)), flags)); + + /// + public Task SortedSetScoresAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Scores(key, Required(members, nameof(members)), flags).AsTask(); + + /// + public long SortedSetLength(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Length(key, min, max, exclude, flags)); + + /// + public Task SortedSetLengthAsync(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Length(key, min, max, exclude, flags).AsTask(); + + /// + public long SortedSetLengthByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.LengthByValue(key, min, max, exclude, flags)); + + /// + public Task SortedSetLengthByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.LengthByValue(key, min, max, exclude, flags).AsTask(); + + /// + public long? SortedSetRank(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Rank(key, member, order, flags)); + + /// + public Task SortedSetRankAsync(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Rank(key, member, order, flags).AsTask(); + + /// + public RedisValue SortedSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RandomMember(key, flags)); + + /// + public Task SortedSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RandomMember(key, flags).AsTask(); + + /// + public RedisValue[] SortedSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RandomMembers(key, count, flags)); + + /// + public Task SortedSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RandomMembers(key, count, flags).AsTask(); + + /// + public SortedSetEntry[] SortedSetRandomMembersWithScores(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RandomMembersWithScores(key, count, flags)); + + /// + public Task SortedSetRandomMembersWithScoresAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RandomMembersWithScores(key, count, flags).AsTask(); + + // ---- ranges ------------------------------------------------------------------------------------ + + /// + public RedisValue[] SortedSetRangeByRank(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeByRank(key, start, stop, order, flags)); + + /// + public Task SortedSetRangeByRankAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeByRank(key, start, stop, order, flags).AsTask(); + + /// + public SortedSetEntry[] SortedSetRangeByRankWithScores(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeByRankWithScores(key, start, stop, order, flags)); + + /// + public Task SortedSetRangeByRankWithScoresAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeByRankWithScores(key, start, stop, order, flags).AsTask(); + + /// + public RedisValue[] SortedSetRangeByScore(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeByScore(key, start, stop, exclude, order, skip, take, flags)); + + /// + public Task SortedSetRangeByScoreAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeByScore(key, start, stop, exclude, order, skip, take, flags).AsTask(); + + /// + public SortedSetEntry[] SortedSetRangeByScoreWithScores(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags)); + + /// + public Task SortedSetRangeByScoreWithScoresAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags).AsTask(); + + /// + public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) + => SortedSetRangeByValue(key, min, max, exclude, Order.Ascending, skip, take, flags); + + /// + public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) + => SortedSetRangeByValueAsync(key, min, max, exclude, Order.Ascending, skip, take, flags); + + /// + public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeByValue(key, min, max, exclude, order, skip, take, flags)); + + /// + public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeByValue(key, min, max, exclude, order, skip, take, flags).AsTask(); + + /// + public long SortedSetRangeAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RangeAndStore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags)); + + /// + public Task SortedSetRangeAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RangeAndStore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags).AsTask(); + + // ---- removal by range -------------------------------------------------------------------------- + + /// + public long SortedSetRemoveRangeByRank(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RemoveRangeByRank(key, start, stop, flags)); + + /// + public Task SortedSetRemoveRangeByRankAsync(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RemoveRangeByRank(key, start, stop, flags).AsTask(); + + /// + public long SortedSetRemoveRangeByScore(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RemoveRangeByScore(key, start, stop, exclude, flags)); + + /// + public Task SortedSetRemoveRangeByScoreAsync(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RemoveRangeByScore(key, start, stop, exclude, flags).AsTask(); + + /// + public long SortedSetRemoveRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.RemoveRangeByValue(key, min, max, exclude, flags)); + + /// + public Task SortedSetRemoveRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.RemoveRangeByValue(key, min, max, exclude, flags).AsTask(); + + // ---- combinations ------------------------------------------------------------------------------ + + /// + public RedisValue[] SortedSetCombine(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Combine(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); + + /// + public Task SortedSetCombineAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Combine(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); + + /// + public SortedSetEntry[] SortedSetCombineWithScores(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.CombineWithScores(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); + + /// + public Task SortedSetCombineWithScoresAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.CombineWithScores(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); + + /// + public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.CombineAndStore(operation, destination, [first, second], default, aggregate, flags)); + + /// + public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.CombineAndStore(operation, destination, [first, second], default, aggregate, flags).AsTask(); + + /// + public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.CombineAndStore(operation, destination, Required(keys, nameof(keys)), weights, aggregate, flags)); + + /// + public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.CombineAndStore(operation, destination, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); + + /// + public long SortedSetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.CombineLength(Required(keys, nameof(keys)), limit, flags)); + + /// + public Task SortedSetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.CombineLength(Required(keys, nameof(keys)), limit, flags).AsTask(); + + // ---- pops -------------------------------------------------------------------------------------- + + /// + public SortedSetEntry? SortedSetPop(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Pop(key, order, flags)); + + /// + public Task SortedSetPopAsync(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Pop(key, order, flags).AsTask(); + + /// + public SortedSetEntry[] SortedSetPop(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Pop(key, count, order, flags)); + + /// + public Task SortedSetPopAsync(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Pop(key, count, order, flags).AsTask(); + + /// + public SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Wait(Context.SortedSets.Pop(Required(keys, nameof(keys)), count, order, flags)); + + /// + public Task SortedSetPopAsync(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => Context.SortedSets.Pop(Required(keys, nameof(keys)), count, order, flags).AsTask(); + + /// + /// The existence half of a , which is all ZADD can express. + /// + /// + /// A value or digest test has no sorted-set spelling; rejecting it here reuses the condition's own + /// message rather than inventing a second one. + /// + private static SortedSetWhen AsSortedSetWhen(in ValueCondition when) => when.Kind switch + { + ValueCondition.ConditionKind.Always => SortedSetWhen.Always, + ValueCondition.ConditionKind.Exists => SortedSetWhen.Exists, + ValueCondition.ConditionKind.NotExists => SortedSetWhen.NotExists, + _ => ThrowUnsupported(when), + }; + + private static SortedSetWhen ThrowUnsupported(in ValueCondition when) + { + when.ThrowInvalidOperation(nameof(SortedSetIncrement)); + return default; // not reached + } + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 91a440068..deb91e735 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -157,6 +157,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSets.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespSets.RespSets() -> void [SER010]StackExchange.Redis.Interpolated.RespSets.RespSets(in StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespSortedSets +[SER010]StackExchange.Redis.Interpolated.RespSortedSets.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespSortedSets.RespSortedSets() -> void +[SER010]StackExchange.Redis.Interpolated.RespSortedSets.RespSortedSets(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespStrings.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespStrings.RespStrings() -> void @@ -166,11 +170,13 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Sets.get -> StackExchange.Redis.Interpolated.RespSets +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).SortedSets.get -> StackExchange.Redis.Interpolated.RespSortedSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Sets.get -> StackExchange.Redis.Interpolated.RespSets +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).SortedSets.get -> StackExchange.Redis.Interpolated.RespSortedSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]override StackExchange.Redis.Interpolated.RespCommand.ToString() -> string! [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool @@ -199,10 +205,16 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double score, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Append(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineAndStore(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineAndStore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineLength(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, long limit = 0, bool approximate = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineLength(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, System.ReadOnlySpan keys, long limit = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -235,6 +247,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetTimeToLive(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double value, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -242,7 +255,9 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.Keys(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double min = -Infinity, double max = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.LengthByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequence(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -252,14 +267,34 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, System.ReadOnlySpan keys, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomField(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMember(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMember(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembersWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeAndStore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.RedisValue start, StackExchange.Redis.RedisValue stop, StackExchange.Redis.SortedSetOrder sortedSetOrder = StackExchange.Redis.SortedSetOrder.ByRank, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long? take = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRankWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScoreWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue max = default(StackExchange.Redis.RedisValue), StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Rank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByRank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start, long stop, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Score(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Scores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -277,6 +312,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespHashes [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespSets [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespSets +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_SortedSets(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespSortedSets +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_SortedSets(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespSortedSets [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespStrings [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 3f971010b..478494e9c 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -4725,7 +4725,12 @@ protected override void WriteImpl(in MessageWriter writer) public override int ArgCount => argCount; } - private static RedisValue GetRange(double value, Exclude exclude, bool isStart) + /// + /// A score bound, with the ( prefix that means exclusive. Shared with the interpolated + /// surface rather than restated: the prefix is the whole of the convention, and a second copy of + /// it would be a silent off-by-one-bound waiting to happen. + /// + internal static RedisValue GetRange(double value, Exclude exclude, bool isStart) { if (isStart) { @@ -5873,7 +5878,11 @@ public long SortedSetLengthByValue(RedisKey key, RedisValue min, RedisValue max, public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take, CommandFlags flags) => SortedSetRangeByValue(key, min, max, exclude, Order.Ascending, skip, take, flags); - private static void ReverseLimits(Order order, ref Exclude exclude, ref RedisValue start, ref RedisValue stop) + /// + /// Put a lexical range into the low-then-high order the server always wants, whichever direction it + /// is asked to walk, swapping the exclusivity with it. Shared with the interpolated surface. + /// + internal static void ReverseLimits(Order order, ref Exclude exclude, ref RedisValue start, ref RedisValue stop) { bool reverseLimits = (order == Order.Ascending) == (stop != default && start.CompareTo(stop) > 0); if (reverseLimits) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 19ab4f745..1446c99d5 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -690,27 +690,12 @@ internal sealed class SortedSetEntryProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - // Handle array with at least 2 elements: [element, score, ...], or null/empty array - if (reader.IsAggregate) - { - SortedSetEntry? result = null; - - // Note: null arrays report false for TryMoveNext, so no explicit null check needed - if (reader.TryMoveNext() && reader.IsScalar) - { - var element = reader.ReadRedisValue(); - if (reader.TryMoveNext() && reader.IsScalar) - { - var score = reader.TryReadDouble(out var val) ? val : double.NaN; - result = new SortedSetEntry(element, score); - } - } - - SetResult(message, result); - return true; - } + // the shape lives on the type it produces, so the interpolated surface's handler reads the + // identical reply the identical way; see SortedSetEntry.Resp.cs + if (!Redis.SortedSetEntry.TryRead(ref reader, out var result)) return false; - return false; + SetResult(message, result); + return true; } } @@ -724,47 +709,12 @@ internal sealed class SortedSetPopResultProcessor : ResultProcessor - { - // Each entry is an array of 2: [element, score] - if (r.IsAggregate && r.TryMoveNext() && r.IsScalar) - { - var element = r.ReadRedisValue(); - if (r.TryMoveNext() && r.IsScalar) - { - var score = r.TryReadDouble(out var val) ? val : double.NaN; - return new SortedSetEntry(element, score); - } - } - return default; - }, - scalar: false); - - SetResult(message, new SortedSetPopResult(key, entries!)); - return true; - } - } - } - - return false; + SetResult(message, result); + return true; } } diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs new file mode 100644 index 000000000..395e2e3d4 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// What the sorted-set group puts on the wire, byte for byte, against a fake executor. +/// +/// +/// +/// +/// This group needs them most. Nearly every command has formatting the caller never sees - a ( on +/// an exclusive bound, a [ on an inclusive lexical one, bounds that swap when the direction and the +/// numbers disagree, operands that vanish at their defaults - and a server will happily accept most of the +/// ways of getting that wrong, returning a plausible answer to a different question. +/// +/// +public class RespSurfaceSortedSetsTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public List Flags { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + Flags.Add(request.Flags); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static (RespContext Context, FakeExecutor Executor) Target(params string[] replies) + { + var executor = new FakeExecutor(replies.Length == 0 ? [":1\r\n"] : replies); + return (new RespContext().WithExecutor(executor), executor); + } + + [Fact] + public async Task AnEntryWritesScoreThenElement() + { + var (ctx, exec) = Target(); + + SortedSetEntry[] entries = [new("a", 1), new("b", 2)]; + await ctx.SortedSets.Add("k", entries); + + // the reverse of how SortedSetEntry reads, and the order ZADD wants; getting this backwards + // produces a command the server accepts and misinterprets + Assert.Equal("*6|$4|ZADD|$1|k|$1|1|$1|a|$1|2|$1|b|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task AddRendersItsOptionsInTheDocumentedOrder() + { + var (ctx, exec) = Target(); + + await ctx.SortedSets.Add("k", "m", 1); + await ctx.SortedSets.Add("k", "m", 1, SortedSetWhen.GreaterThan, change: true); + await ctx.SortedSets.Add("k", "m", 1, SortedSetWhen.NotExists); + + Assert.Equal( + new[] + { + "*4|$4|ZADD|$1|k|$1|1|$1|m|", + "*6|$4|ZADD|$1|k|$2|GT|$2|CH|$1|1|$1|m|", + "*5|$4|ZADD|$1|k|$2|NX|$1|1|$1|m|", + }, + exec.Sent); + } + + [Fact] + public async Task AddsRetryCategoryDependsOnItsOptions() + { + var (ctx, exec) = Target(); + + await ctx.SortedSets.Add("k", "m", 1); + await ctx.SortedSets.Add("k", "m", 1, SortedSetWhen.Exists); + await ctx.SortedSets.Increment("k", "m", 1, SortedSetWhen.Exists); + await ctx.SortedSets.Increment("k", "m", 1, SortedSetWhen.NotExists); + + // a bare ZADD overwrites, so last-wins; NX/XX/GT/LT make a replay converge, so checked; INCR + // compounds - unless NX, where a replay can only find the member present and no-op + Assert.Equal(CommandFlags.CommandRetryWriteLastWins, exec.Flags[0] & Message.MaskRetryCategory); + Assert.Equal(CommandFlags.CommandRetryWriteChecked, exec.Flags[1] & Message.MaskRetryCategory); + Assert.Equal(CommandFlags.CommandRetryWriteAccumulating, exec.Flags[2] & Message.MaskRetryCategory); + Assert.Equal(CommandFlags.CommandRetryWriteChecked, exec.Flags[3] & Message.MaskRetryCategory); + } + + [Fact] + public async Task AnUnconditionalIncrementIsTheShorterCommand() + { + var (ctx, exec) = Target("$1\r\n5\r\n"); + + await ctx.SortedSets.Increment("k", "m", 5); + await ctx.SortedSets.Increment("k", "m", 5, SortedSetWhen.Exists); + + Assert.Equal( + new[] + { + "*4|$7|ZINCRBY|$1|k|$1|5|$1|m|", + "*6|$4|ZADD|$1|k|$2|XX|$4|INCR|$1|5|$1|m|", + }, + exec.Sent); + } + + [Fact] + public async Task AnUnboundedLengthIsADifferentCommand() + { + var (ctx, exec) = Target(); + + await ctx.SortedSets.Length("k"); + await ctx.SortedSets.Length("k", 1, 10); + await ctx.SortedSets.Length("k", 1, 10, Exclude.Start); + + // the whole set is what ZCARD answers without walking anything; and an exclusive bound is a '(' + Assert.Equal( + new[] + { + "*2|$5|ZCARD|$1|k|", + "*4|$6|ZCOUNT|$1|k|$1|1|$2|10|", + "*4|$6|ZCOUNT|$1|k|$2|(1|$2|10|", + }, + exec.Sent); + } + + [Fact] + public async Task AScoreRangeIsPutIntoLowThenHighOrder() + { + var (ctx, exec) = Target("*0\r\n"); + + await ctx.SortedSets.RangeByScore("k", 1, 10); + await ctx.SortedSets.RangeByScore("k", 10, 1, order: Order.Descending); + await ctx.SortedSets.RangeByScore("k", 10, 1, Exclude.Start, Order.Descending); + await ctx.SortedSets.RangeByScore("k", 1, 10, Exclude.Start, Order.Descending); + + // each command wants its bounds in the direction it walks - ascending low-then-high, descending + // high-then-low - so a caller who wrote them the other way round gets them swapped, and the + // exclusivity swaps with them or the range quietly moves by one member. The first three are + // already in the right order for their command and pass through; the fourth is not. + Assert.Equal( + new[] + { + "*4|$13|ZRANGEBYSCORE|$1|k|$1|1|$2|10|", + "*4|$16|ZREVRANGEBYSCORE|$1|k|$2|10|$1|1|", + "*4|$16|ZREVRANGEBYSCORE|$1|k|$3|(10|$1|1|", + "*4|$16|ZREVRANGEBYSCORE|$1|k|$2|10|$2|(1|", + }, + exec.Sent); + } + + [Fact] + public async Task AWindowIsOnlyWrittenWhenOneWasAskedFor() + { + var (ctx, exec) = Target("*0\r\n"); + + await ctx.SortedSets.RangeByScore("k", 1, 10); + await ctx.SortedSets.RangeByScore("k", 1, 10, skip: 5, take: 2); + await ctx.SortedSets.RangeByScoreWithScores("k", 1, 10, skip: 5, take: 2); + + Assert.Equal( + new[] + { + "*4|$13|ZRANGEBYSCORE|$1|k|$1|1|$2|10|", + "*7|$13|ZRANGEBYSCORE|$1|k|$1|1|$2|10|$5|LIMIT|$1|5|$1|2|", + "*8|$13|ZRANGEBYSCORE|$1|k|$1|1|$2|10|$10|WITHSCORES|$5|LIMIT|$1|5|$1|2|", + }, + exec.Sent); + } + + [Fact] + public async Task AnOpenLexicalBoundFlipsWithTheDirection() + { + var (ctx, exec) = Target("*0\r\n"); + + await ctx.SortedSets.RangeByValue("k"); + await ctx.SortedSets.RangeByValue("k", order: Order.Descending); + await ctx.SortedSets.RangeByValue("k", "a", "j"); + await ctx.SortedSets.RangeByValue("k", "a", "j", Exclude.Both); + + // '-' and '+' are chosen by the ORDER, not by the position; the bounds themselves stay in + // start-then-stop order even for the reversed command + Assert.Equal( + new[] + { + "*4|$11|ZRANGEBYLEX|$1|k|$1|-|$1|+|", + "*4|$14|ZREVRANGEBYLEX|$1|k|$1|+|$1|-|", + "*4|$11|ZRANGEBYLEX|$1|k|$2|[a|$2|[j|", + "*4|$11|ZRANGEBYLEX|$1|k|$2|(a|$2|(j|", + }, + exec.Sent); + } + + [Fact] + public async Task RangeAndStoreBracketsAnInclusiveLexicalBound() + { + var (ctx, exec) = Target(); + + await ctx.SortedSets.RangeAndStore("src", "dst", 0, -1); + await ctx.SortedSets.RangeAndStore("src", "dst", "a", "j", SortedSetOrder.ByLex); + await ctx.SortedSets.RangeAndStore("src", "dst", 1, 10, SortedSetOrder.ByScore); + await ctx.SortedSets.RangeAndStore("src", "dst", "a", "j", SortedSetOrder.ByLex, take: 3); + + // the destination comes FIRST, and a by-lex bound is bracketed where a by-score one is bare - + // an asymmetry that belongs to the server and is exactly what gets "tidied" into a bug + Assert.Equal( + new[] + { + "*5|$11|ZRANGESTORE|$3|dst|$3|src|$1|0|$2|-1|", + "*6|$11|ZRANGESTORE|$3|dst|$3|src|$2|[a|$2|[j|$5|BYLEX|", + "*6|$11|ZRANGESTORE|$3|dst|$3|src|$1|1|$2|10|$7|BYSCORE|", + "*9|$11|ZRANGESTORE|$3|dst|$3|src|$2|[a|$2|[j|$5|BYLEX|$5|LIMIT|$1|0|$1|3|", + }, + exec.Sent); + } + + [Fact] + public void RangeAndStoreRefusesOperandsItHasNowhereToPut() + { + var (ctx, _) = Target(); + + // by rank the server has no operand for either, so dropping them silently would store a + // different range than was asked for + Assert.Throws(() => ctx.SortedSets.RangeAndStore("s", "d", 0, -1, take: 3)); + Assert.Throws(() => ctx.SortedSets.RangeAndStore("s", "d", 0, -1, exclude: Exclude.Start)); + } + + [Fact] + public async Task CombinationsCountTheirKeysAndDropTheirDefaults() + { + var (ctx, exec) = Target("*0\r\n", "*0\r\n", "*0\r\n", ":0\r\n"); + + RedisKey[] keys = ["a", "b"]; + await ctx.SortedSets.Combine(SetOperation.Union, keys); + await ctx.SortedSets.Combine(SetOperation.Union, keys, [1, 2]); + await ctx.SortedSets.CombineWithScores(SetOperation.Intersect, keys, default, Aggregate.Max); + await ctx.SortedSets.CombineAndStore(SetOperation.Union, "dest", keys, [1, 2], Aggregate.Count); + + // SUM is the server's own default and renders as nothing; the STORE form puts its destination + // before the count, which is the one place the key list is not what follows numkeys + Assert.Equal( + new[] + { + "*4|$6|ZUNION|$1|2|$1|a|$1|b|", + "*7|$6|ZUNION|$1|2|$1|a|$1|b|$7|WEIGHTS|$1|1|$1|2|", + "*7|$6|ZINTER|$1|2|$1|a|$1|b|$9|AGGREGATE|$3|MAX|$10|WITHSCORES|", + "*10|$11|ZUNIONSTORE|$4|dest|$1|2|$1|a|$1|b|$7|WEIGHTS|$1|1|$1|2|$9|AGGREGATE|$5|COUNT|", + }, + exec.Sent); + } + + [Fact] + public void CombinationsRejectWhatTheCommandCannotCarry() + { + var (ctx, _) = Target(); + RedisKey[] keys = ["a", "b"]; + + // the message names whichever command was asked for, as the old surface does + var diff = Assert.Throws(() => ctx.SortedSets.Combine(SetOperation.Difference, keys, [1, 2])); + Assert.StartsWith("ZDIFF ", diff.Message); + + var store = Assert.Throws(() => ctx.SortedSets.CombineAndStore(SetOperation.Difference, "d", keys, [1, 2])); + Assert.StartsWith("ZDIFFSTORE ", store.Message); + + Assert.Throws(() => ctx.SortedSets.Combine(SetOperation.Union, keys, [1])); + Assert.Throws(() => ctx.SortedSets.Combine(SetOperation.Union, ReadOnlySpan.Empty)); + } + + [Fact] + public async Task PopsNameTheirEnd() + { + var (ctx, exec) = Target("*2\r\n$1\r\na\r\n$1\r\n1\r\n"); + + await ctx.SortedSets.Pop("k"); + await ctx.SortedSets.Pop("k", 2, Order.Descending); + + Assert.Equal( + new[] { "*2|$7|ZPOPMIN|$1|k|", "*3|$7|ZPOPMAX|$1|k|$1|2|" }, + exec.Sent); + } + + [Fact] + public async Task PoppingNoneAsksNobody() + { + var (ctx, exec) = Target(); + + // `count: 0` rather than a bare 0, because Order is an enum and so a literal zero is ambiguous + // between the two overloads - a wart this surface inherits from the pair it replaces + Assert.Empty(await ctx.SortedSets.Pop("k", count: 0)); + Assert.Empty(exec.Sent); + } + + [Fact] + public async Task MultiPopCountsItsKeysAndNamesItsEnd() + { + var (ctx, exec) = Target("*-1\r\n"); + + RedisKey[] keys = ["a", "b"]; + await ctx.SortedSets.Pop(keys, 3, Order.Descending); + + Assert.Equal("*7|$5|ZMPOP|$1|2|$1|a|$1|b|$3|MAX|$5|COUNT|$1|3|", Assert.Single(exec.Sent)); + } + + [Fact] + public void MultiPopNeedsAKey() + { + var (ctx, _) = Target(); + + var ex = Assert.Throws(() => ctx.SortedSets.Pop(ReadOnlySpan.Empty, 1)); + Assert.Contains("keys must have a size of at least 1", ex.Message); + } + + [Fact] + public async Task ScoresComeBackNullableBecauseAMemberMayBeAbsent() + { + var (ctx, exec) = Target("*3\r\n$1\r\n1\r\n$-1\r\n$1\r\n3\r\n"); + + RedisValue[] members = ["a", "b", "c"]; + Assert.Equal(new double?[] { 1, null, 3 }, await ctx.SortedSets.Scores("k", members)); + + Assert.Equal("*5|$7|ZMSCORE|$1|k|$1|a|$1|b|$1|c|", Assert.Single(exec.Sent)); + } + + [Fact] + public async Task IntersectionLengthCarriesItsLimitOrNothing() + { + var (ctx, exec) = Target(); + + RedisKey[] keys = ["a", "b"]; + await ctx.SortedSets.CombineLength(keys); + await ctx.SortedSets.CombineLength(keys, limit: 7); + + Assert.Equal( + new[] + { + "*4|$10|ZINTERCARD|$1|2|$1|a|$1|b|", + "*6|$10|ZINTERCARD|$1|2|$1|a|$1|b|$5|LIMIT|$1|7|", + }, + exec.Sent); + } +} diff --git a/tests/StackExchange.Redis.Tests/SortedSetTests.cs b/tests/StackExchange.Redis.Tests/SortedSetTests.cs index b8009748c..6e89f76a8 100644 --- a/tests/StackExchange.Redis.Tests/SortedSetTests.cs +++ b/tests/StackExchange.Redis.Tests/SortedSetTests.cs @@ -63,7 +63,7 @@ public async Task SortedSetCombine() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -90,7 +90,7 @@ public async Task SortedSetCombineAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -117,7 +117,7 @@ public async Task SortedSetCombineWithScores() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -144,7 +144,7 @@ public async Task SortedSetCombineWithScoresAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -171,7 +171,7 @@ public async Task SortedSetCombineAndStore() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -197,7 +197,7 @@ public async Task SortedSetCombineAndStoreAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -223,7 +223,7 @@ public async Task SortedSetCombineErrors() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -285,7 +285,7 @@ public async Task SortedSetIntersectionLength() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -307,7 +307,7 @@ public async Task SortedSetIntersectionLengthAsync() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -329,7 +329,7 @@ public async Task SortedSetCombineAggregateCount() { await using var conn = Create(require: RedisFeatures.v8_8_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key1 = Me(); db.KeyDelete(key1, CommandFlags.FireAndForget); var key2 = Me() + "2"; @@ -370,7 +370,7 @@ public async Task SortedSetCombineAggregateCount() public async Task SortedSetRangeViaScript() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -384,7 +384,7 @@ public async Task SortedSetRangeViaScript() public async Task SortedSetRangeViaExecute() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -446,7 +446,7 @@ public async Task SortedSetPopMulti_Multi() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -469,7 +469,7 @@ public async Task SortedSetPopMulti_Single() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -491,7 +491,7 @@ public async Task SortedSetPopMulti_Multi_Async() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -514,7 +514,7 @@ public async Task SortedSetPopMulti_Single_Async() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -536,7 +536,7 @@ public async Task SortedSetPopMulti_Zero_Async() { await using var conn = Create(require: RedisFeatures.v5_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -556,7 +556,7 @@ public async Task SortedSetRandomMembers() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); var key0 = Me() + "non-existing"; @@ -600,7 +600,7 @@ public async Task SortedSetRandomMembersAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); var key0 = Me() + "non-existing"; @@ -643,7 +643,7 @@ public async Task SortedSetRangeStoreByRankAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -659,7 +659,7 @@ public async Task SortedSetRangeStoreByRankLimitedAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -680,7 +680,7 @@ public async Task SortedSetRangeStoreByScoreAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -701,7 +701,7 @@ public async Task SortedSetRangeStoreByScoreAsyncDefault() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -722,7 +722,7 @@ public async Task SortedSetRangeStoreByScoreAsyncLimited() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -743,7 +743,7 @@ public async Task SortedSetRangeStoreByScoreAsyncExclusiveRange() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -764,7 +764,7 @@ public async Task SortedSetRangeStoreByScoreAsyncReverse() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -785,7 +785,7 @@ public async Task SortedSetRangeStoreByLexAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -806,7 +806,7 @@ public async Task SortedSetRangeStoreByLexExclusiveRangeAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -827,7 +827,7 @@ public async Task SortedSetRangeStoreByLexRevRangeAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -848,7 +848,7 @@ public async Task SortedSetRangeStoreByRank() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -864,7 +864,7 @@ public async Task SortedSetRangeStoreByRankLimited() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -885,7 +885,7 @@ public async Task SortedSetRangeStoreByScore() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -906,7 +906,7 @@ public async Task SortedSetRangeStoreByScoreDefault() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -927,7 +927,7 @@ public async Task SortedSetRangeStoreByScoreLimited() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -948,7 +948,7 @@ public async Task SortedSetRangeStoreByScoreExclusiveRange() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -969,7 +969,7 @@ public async Task SortedSetRangeStoreByScoreReverse() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -990,7 +990,7 @@ public async Task SortedSetRangeStoreByLex() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1011,7 +1011,7 @@ public async Task SortedSetRangeStoreByLexExclusiveRange() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1032,7 +1032,7 @@ public async Task SortedSetRangeStoreByLexRevRange() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1053,7 +1053,7 @@ public async Task SortedSetRangeStoreFailErroneousTake() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1069,7 +1069,7 @@ public async Task SortedSetRangeStoreFailExclude() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1085,7 +1085,7 @@ public async Task SortedSetMultiPopSingleKey() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key); @@ -1121,7 +1121,7 @@ public async Task SortedSetMultiPopMultiKey() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); RedisKey[] keys = [key + ":missing1", key, key + ":missing2"]; db.KeyDelete(keys); @@ -1158,7 +1158,7 @@ public async Task SortedSetMultiPopNoSet() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); RedisKey[] keys = [key + ":missing1", key, key + ":missing2"]; db.KeyDelete(keys); @@ -1171,7 +1171,7 @@ public async Task SortedSetMultiPopCount0() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key); var exception = Assert.Throws(() => db.SortedSetPop([key], 0)); @@ -1183,7 +1183,7 @@ public async Task SortedSetMultiPopAsync() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); RedisKey[] keys = [key + ":missing1", key, key + ":missing2"]; db.KeyDelete(keys); @@ -1221,7 +1221,7 @@ public async Task SortedSetMultiPopEmptyKeys() { await using var conn = Create(require: RedisFeatures.v7_0_0_rc1); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var exception = Assert.Throws(() => db.SortedSetPop(Array.Empty(), 5)); Assert.Contains("keys must have a size of at least 1", exception.Message); } @@ -1231,7 +1231,7 @@ public async Task SortedSetRangeStoreFailForReplica() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var me = Me(); var sourceKey = $"{me}:ZSetSource"; var destinationKey = $"{me}:ZSetDestination"; @@ -1247,7 +1247,7 @@ public async Task SortedSetScoresSingle() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string memberName = "member"; @@ -1265,7 +1265,7 @@ public async Task SortedSetScoresSingleAsync() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string memberName = "member"; @@ -1283,7 +1283,7 @@ public async Task SortedSetScoresSingle_MissingSetStillReturnsNull() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key); @@ -1299,7 +1299,7 @@ public async Task SortedSetScoresSingle_MissingSetStillReturnsNullAsync() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); @@ -1315,7 +1315,7 @@ public async Task SortedSetScoresSingle_ReturnsNullForMissingMember() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key); @@ -1332,7 +1332,7 @@ public async Task SortedSetScoresSingle_ReturnsNullForMissingMemberAsync() { await using var conn = Create(require: RedisFeatures.v2_1_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); @@ -1349,7 +1349,7 @@ public async Task SortedSetScoresMultiple() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string member1 = "member1", member2 = "member2", @@ -1374,7 +1374,7 @@ public async Task SortedSetScoresMultipleAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string member1 = "member1", member2 = "member2", @@ -1399,7 +1399,7 @@ public async Task SortedSetScoresMultiple_ReturnsNullItemsForMissingSet() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); db.KeyDelete(key); @@ -1419,7 +1419,7 @@ public async Task SortedSetScoresMultiple_ReturnsNullItemsForMissingSetAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); await db.KeyDeleteAsync(key); @@ -1439,7 +1439,7 @@ public async Task SortedSetScoresMultiple_ReturnsScoresAndNullItems() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string member1 = "member1", member2 = "member2", @@ -1467,7 +1467,7 @@ public async Task SortedSetScoresMultiple_ReturnsScoresAndNullItemsAsync() { await using var conn = Create(require: RedisFeatures.v6_2_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); const string member1 = "member1", member2 = "member2", @@ -1495,7 +1495,7 @@ public async Task SortedSetUpdate() { await using var conn = Create(require: RedisFeatures.v3_0_0); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); var member = "a"; var values = new SortedSetEntry[] { new SortedSetEntry(member, 5) }; @@ -1514,7 +1514,7 @@ public async Task SortedSetRemoveArgTests() { await using var conn = Create(); - var db = conn.GetDatabase(); + var db = GetDatabase(conn); var key = Me(); RedisValue[]? members = null; diff --git a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs index 80bd94b54..d7725b511 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs @@ -91,6 +91,15 @@ protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = - => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); } +/// +[RunPerProtocol] +public class TransitionalSortedSetTests(ITestOutputHelper output, SharedConnectionFixture fixture) + : SortedSetTests(output, fixture) +{ + protected override IDatabase GetDatabase(IConnectionMultiplexer conn, int db = -1, object? asyncState = null) + => TransitionalSurfaceFixture.Wrap(conn, db, asyncState); +} + /// /// That the re-runs above are actually re-running anything. /// @@ -125,6 +134,7 @@ private static string[] Generated(string prefix, Type iface) [InlineData("String")] [InlineData("Hash")] [InlineData("Set")] + [InlineData("SortedSet")] public void EveryMemberOfAMovedGroupIsImplemented(string prefix) { var generated = Generated(prefix, typeof(IDatabase)).Concat(Generated(prefix, typeof(IDatabaseAsync))) @@ -141,6 +151,7 @@ public void EveryMemberOfAMovedGroupIsImplemented(string prefix) .Where(x => !x.StartsWith("HashImport", StringComparison.Ordinal)) .Where(x => !x.StartsWith("HashScan", StringComparison.Ordinal)) .Where(x => !x.StartsWith("SetScan", StringComparison.Ordinal)) + .Where(x => !x.StartsWith("SortedSetScan", StringComparison.Ordinal)) .ToArray(); Assert.Empty(expected); @@ -152,6 +163,6 @@ public void AnUnmovedGroupIsStillGenerated() // the control. Without this, EveryMemberOfAMovedGroupIsImplemented would pass just as happily if // the interface map stopped distinguishing the two kinds of member, and the coverage claim above // would be vacuous rather than wrong - which is the harder failure to notice. - Assert.NotEmpty(Generated("SortedSet", typeof(IDatabase))); + Assert.NotEmpty(Generated("List", typeof(IDatabase))); } } From a539a53805506b64274e0fb7c6568bc1952514f3 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 18:48:44 +0100 Subject: [PATCH 122/360] Registration is implementing the interface Replaces a typeof(T) == ladder that had reached 25 entries and that nobody was obliged to extend. One DefaultHandlers object carries every default as an explicit implementation, and the lookup is: private static IRespHandler? Resolve() => DefaultHandlers.Instance as IRespHandler; memoized in the same static field as before, so it still runs once per closed T. A handler that compiles is a handler that is reachable; there is no ladder to keep in step and no list to forget. It also makes "two defaults for one result type" a COMPILE error, which neither a typeof chain nor a registration array manages - both pick silently. That is not hypothetical here: bool has Boolean and Success, RedisValue has Value and SingletonValue, Lease has Lease and SingletonLease. Exactly one of each lives on the singleton; the alternates stay as their own classes and are named explicitly by the commands that want them, which is the honest way to say "not the default". IRespHandler and IRespPayloadHandler lose their covariance, which this depends on: `as` honours variance, so IRespHandler would let a string handler answer SendAsync. Checked that nothing relied on it. Explicit implementation is forced rather than chosen - one Parse per result type, differing only by return type, is not a legal implicit overload set - and conveniently keeps 25 Parse methods off the type's own surface. Two things the merge surfaced that separate classes had hidden: two handlers each had a private helper called Shape, now named for what they shape; and two class-level doc comments were orphaned when their classes moved, one of which was already stale (it still claimed RespResult copies). RespSurface.cs: 600 -> 535 lines. 6933 tests green. --- design/interpolated-resp-writer.queue.md | 35 +- .../Interpolated/RespExecutor.cs | 4 +- .../Interpolated/RespSurface.cs | 405 ++++++++---------- .../RespHandlerRegistryTests.cs | 107 +++++ 4 files changed, 281 insertions(+), 270 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespHandlerRegistryTests.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 147e10076..64ec09410 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -18,38 +18,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. -- [ ] **Replace the `Inbuilt` type registry with interface-based lookup.** Today it is a chain of - `typeof(T) == typeof(X)` tests that someone must remember to extend. Instead: - - ```csharp - internal static class DefaultHandler - { - internal static readonly IRespHandler? Instance = RespHandlers.Defaults as IRespHandler; - } - ``` - - where `Defaults` is one singleton carrying many explicit `IRespHandler` implementations. - Registration becomes "implement the interface", which cannot fall out of step, and memoization is - unchanged (`static readonly` on a closed generic either way). - - **`IRespHandler` must lose its `out` first.** `as` honours variance, so covariance would - let `as IRespHandler` silently match an `IRespHandler` implementation and return a - string parser. Verified that nothing depends on the variance: the whole repo builds with it invariant. - - Explicit implementation is forced anyway (one `Parse` per `T`, differing only by return type, is not a - legal implicit overload set), which conveniently keeps them off the singleton's public surface. The - named vocabulary (`Value`, `Ok`, `Result`, `ReadOnlyLease`) stays as properties over the same - singleton, preserving the deliberate "named versus merely registered" distinction. - - Collides with in-flight work: `RespHandlers` lives in `RespSurface.cs`. Do it right after a merge, - with the rename above. - -- [ ] **Stale-while-revalidate** (§6.15). Soft/hard thresholds on `CachePolicy`, once-only refresh via an - interlocked flag on the entry, clearing on failure with backoff. Prerequisites are in: single-flight - is the same interlock, and `CachePolicy` already carries the lifetime. Remember the cap for the - compounding-staleness case, and that invalidation-SWR must **not** apply to invalidations we caused - ourselves (read-your-own-writes). - - [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and @@ -120,7 +88,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] `ReadOnlyLease`, and retiring the mutable `ReadLease` spelling — `9a1a37bf` - [x] Ad-hoc `ExecuteAsync` returning `RespResult`, on the context and on `IRespTarget`; `ExecuteResp` wired through `TransitionalDatabase` — `30d28d70` -- [x] `RespResult` shares the reply buffer instead of copying it — this change +- [x] `RespResult` shares the reply buffer instead of copying it — `696a5c3f` +- [x] Interface-based default handler lookup; `IRespHandler` made invariant — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 658da8a9c..76e6bdf03 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -50,7 +50,7 @@ internal interface IRespExecutor /// /// What parsing the reply produces. [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public interface IRespHandler + public interface IRespHandler { /// Read a reply - cached or fresh - into a result. /// The reply bytes; valid only for the duration of this call. @@ -79,7 +79,7 @@ public interface IRespHandler /// returns. /// /// - internal interface IRespPayloadHandler : IRespHandler + internal interface IRespPayloadHandler : IRespHandler { /// Parse the reply, optionally retaining its buffer. /// The reply; take a reference if the result outlives this call. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 14a794724..13aa01307 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -33,7 +33,7 @@ public interface IRespTarget public static class RespHandlers { /// Reads a bulk string reply as a ; null stays null. - public static IRespHandler Value { get; } = new ValueHandler(); + public static IRespHandler Value { get; } = DefaultHandlers.Instance; /// Reads a reply as a boolean, in any of the spellings the server uses for one. /// @@ -44,22 +44,22 @@ public static class RespHandlers /// left to decide here is that nil means "no", which is what it means everywhere it appears. /// A separate OK-only handler would buy one branch and cost every caller a decision. /// - public static IRespHandler Boolean { get; } = new BooleanHandler(); + public static IRespHandler Boolean { get; } = DefaultHandlers.Instance; /// Reads an integer reply. - public static IRespHandler Int64 { get; } = new Int64Handler(); + public static IRespHandler Int64 { get; } = DefaultHandlers.Instance; /// Reads an integer reply that may be nil, as BITFIELD's overflow case is. - public static IRespHandler NullableInt64 { get; } = new NullableInt64Handler(); + public static IRespHandler NullableInt64 { get; } = DefaultHandlers.Instance; /// Reads a floating-point reply; RESP2 sends these as bulk strings. - public static IRespHandler Double { get; } = new DoubleHandler(); + public static IRespHandler Double { get; } = DefaultHandlers.Instance; /// Reads an array reply as s; a nil array reads as empty. - public static IRespHandler Values { get; } = new ValuesHandler(); + public static IRespHandler Values { get; } = DefaultHandlers.Instance; /// Reads a bulk string reply as a ; null stays null. - public static IRespHandler String { get; } = new StringHandler(); + public static IRespHandler String { get; } = DefaultHandlers.Instance; /// Reads a bulk string reply as a ; null stays null. /// @@ -67,13 +67,13 @@ public static class RespHandlers /// into the reply's buffer: a handler is handed a span whose lifetime ends when it returns, so /// there is nothing to share. See . /// - public static IRespHandler?> Lease { get; } = new LeaseHandler(); + public static IRespHandler?> Lease { get; } = DefaultHandlers.Instance; /// The reply as a read-only buffer; shares the underlying memory where it can. - public static IRespHandler?> ReadOnlyLease { get; } = new ReadOnlyLeaseHandler(); + public static IRespHandler?> ReadOnlyLease { get; } = DefaultHandlers.Instance; /// The whole reply, undecoded - the general-purpose answer for commands we do not model. - public static IRespHandler Result { get; } = new RespResultHandler(); + public static IRespHandler Result { get; } = DefaultHandlers.Instance; /// Reads a one-element array reply as the single value it wraps. /// @@ -111,66 +111,83 @@ internal static IRespHandler Require() => Handler ?? throw new InvalidOperationException( $"No built-in RESP handler for '{typeof(T).Name}'; pass one explicitly."); - private static IRespHandler? Resolve() - { - object? handler = null; - if (typeof(T) == typeof(RedisValue)) handler = Value; - else if (typeof(T) == typeof(bool)) handler = Boolean; - else if (typeof(T) == typeof(long)) handler = Int64; - else if (typeof(T) == typeof(long?)) handler = NullableInt64; - else if (typeof(T) == typeof(double)) handler = Double; - else if (typeof(T) == typeof(RedisValue[])) handler = Values; - else if (typeof(T) == typeof(string)) handler = String; - else if (typeof(T) == typeof(Lease)) handler = Lease; - else if (typeof(T) == typeof(ReadOnlyLease)) handler = ReadOnlyLease; - else if (typeof(T) == typeof(RespResult)) handler = Result; - - // Below this line: shapes that belong to ONE command. They are registered so a command - // body stays one expression, but they are not exposed as named properties - a handler - // nobody can reuse is not part of a vocabulary, and RespHandlers is the vocabulary. If a - // shape ever earns a second caller, promoting it is a one-line change. - else if (typeof(T) == typeof(ValueCondition?)) handler = s_digest; - else if (typeof(T) == typeof(LCSMatchResult)) handler = s_lcsMatch; - else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementInt64; - else if (typeof(T) == typeof(StringIncrementResult)) handler = s_incrementDouble; - else if (typeof(T) == typeof(Lease)) handler = s_nullableInt64Lease; - else if (typeof(T) == typeof(HashEntry[])) handler = s_hashEntries; - else if (typeof(T) == typeof(long[])) handler = s_int64Array; - else if (typeof(T) == typeof(ExpireResult[])) handler = s_expireResults; - else if (typeof(T) == typeof(PersistResult[])) handler = s_persistResults; - else if (typeof(T) == typeof(bool[])) handler = s_booleans; - else if (typeof(T) == typeof(double?)) handler = s_nullableDouble; - else if (typeof(T) == typeof(double?[])) handler = s_nullableDoubles; - else if (typeof(T) == typeof(SortedSetEntry[])) handler = s_sortedSetEntries; - else if (typeof(T) == typeof(SortedSetEntry?)) handler = s_sortedSetEntry; - else if (typeof(T) == typeof(SortedSetPopResult)) handler = s_sortedSetPop; - return (IRespHandler?)handler; - } + /// + /// Ask the defaults object whether it handles . + /// + /// + /// One cast, memoized by the runtime in the static field above - so this runs once per closed + /// , exactly as the hand-written ladder it replaces did, without anybody + /// having to maintain the ladder. + /// + /// This relies on IRespHandler<T> being invariant. Were it covariant, the cast + /// would honour variance and a handler for a derived type could answer a request for a base one - + /// an IRespHandler<string> quietly serving SendAsync<object>, say. + /// + /// + private static IRespHandler? Resolve() => DefaultHandlers.Instance as IRespHandler; } - private sealed class ValueHandler : IRespHandler + /// + /// Every default reply handler, on one object. + /// + /// + /// + /// Implementing the interface IS the registration. There is no list to remember to extend + /// and no typeof ladder to keep in step: simply asks whether this + /// object is an IRespHandler<T>. A handler that compiles is a handler that is reachable. + /// + /// + /// It also makes "two defaults for one result type" a compile error rather than a silent + /// coin-toss, which neither a typeof chain nor a registration array can manage. That matters + /// here, because several result types genuinely have more than one handler - bool has + /// and , has + /// and . Exactly one of each can live here; the alternates stay as their + /// own classes and are named explicitly by the commands that want them, which is the honest way to + /// say "this is not the default". + /// + /// + /// The implementations are explicit, which is forced rather than chosen: one Parse per result + /// type, differing only by return type, is not a legal implicit overload set. The happy side effect + /// is that none of them clutters this type's own surface. + /// + /// + private sealed class DefaultHandlers : + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler?>, + IRespHandler>, + IRespHandler, + IRespHandler?>, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler>, + IRespHandler>, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespPayloadHandler { - public RedisValue Parse(ReadOnlySpan response) + internal static readonly DefaultHandlers Instance = new(); + + RedisValue IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.IsNull ? RedisValue.Null : reader.ReadRedisValue(); } - } - private sealed class SuccessHandler : IRespHandler - { - public bool Parse(ReadOnlySpan response) - { - var reader = new RespReader(response); - reader.MoveNext(); // skips attributes, and throws RespException on an error element - return true; - } - } - - private sealed class BooleanHandler : IRespHandler - { - public bool Parse(ReadOnlySpan response) + bool IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -179,21 +196,15 @@ public bool Parse(ReadOnlySpan response) // NX/XX that did not write, a GETEX on a missing key - the command worked, the answer is no return !reader.IsNull && reader.ReadBoolean(); } - } - private sealed class Int64Handler : IRespHandler - { - public long Parse(ReadOnlySpan response) + long IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadInt64(); } - } - private sealed class NullableInt64Handler : IRespHandler - { - public long? Parse(ReadOnlySpan response) + long? IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -204,21 +215,15 @@ private sealed class NullableInt64Handler : IRespHandler return reader.IsNull ? null : reader.ReadInt64(); } - } - private sealed class DoubleHandler : IRespHandler - { - public double Parse(ReadOnlySpan response) + double IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadDouble(); } - } - private sealed class ValuesHandler : IRespHandler - { - public RedisValue[] Parse(ReadOnlySpan response) + RedisValue[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -227,42 +232,14 @@ public RedisValue[] Parse(ReadOnlySpan response) // reads as empty rather than null, because every caller of an array reply wants to iterate it return reader.ReadPastRedisValues() ?? Array.Empty(); } - } - private sealed class StringHandler : IRespHandler - { - public string? Parse(ReadOnlySpan response) + string? IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.IsNull ? null : reader.ReadString(); } - } - /// - /// Captures the whole reply, undecoded, as a . - /// - /// - /// - /// The general-purpose answer, and the one that matters most for other people's commands: a - /// library like NRedisStack reaches the server through the escape hatch and wants the reply, not a - /// decoded shape this library happens to know. Registering it here means every such command gets - /// the new surface without anyone enumerating commands - the plumbing lights up once. - /// - /// - /// Note it is not the same as "cacheable": whether a given command's reply may be cached is - /// still per-command, and the server does not track the FT.* family for invalidation at all. - /// What generalises is the mechanism. - /// - /// - /// This copies today, and that is not yet avoidable here. Sharing the buffer needs the reader - /// to know which buffer the bytes live in - RespResult.Read passes it as a reader service for - /// exactly that reason - and a parameter cannot carry it. See design - /// notes 6.16. - /// - /// - private sealed class RespResultHandler : IRespPayloadHandler - { /// /// Share the reply's buffer rather than copying it - one more reference, not a second copy. /// @@ -272,21 +249,18 @@ private sealed class RespResultHandler : IRespPayloadHandler /// owned elsewhere safe. Falls back to a copy if the buffer has already gone - losing that race /// means it is on its way back to the pool, and resurrecting it is exactly what must not happen. /// - public RespResult Parse(RespPayload payload) + RespResult IRespPayloadHandler.Parse(RespPayload payload) => payload.ShareAsResult() ?? RespResult.Capture(payload.Span); /// The copying path, for a caller who only has the bytes. - public RespResult Parse(ReadOnlySpan response) => RespResult.Capture(response); - } + RespResult IRespHandler.Parse(ReadOnlySpan response) => RespResult.Capture(response); - /// The reply as a buffer the caller owns outright, and may write to. - /// - /// Copies, necessarily: a mutable lease must not point at memory anything else can read. The - /// sibling is the one that can share. See design notes 6.16. - /// - private sealed class LeaseHandler : IRespHandler?> - { - public Lease? Parse(ReadOnlySpan response) + /// The reply as a buffer the caller owns outright, and may write to. + /// + /// Copies, necessarily: a mutable lease must not point at memory anything else can read. The + /// sibling is the one that can share. See design notes 6.16. + /// + Lease? IRespHandler?>.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -294,39 +268,15 @@ private sealed class LeaseHandler : IRespHandler?> return RespReaderExtensions.ReadLease(in reader); #pragma warning restore CS0618 } - } - /// The reply as a read-only buffer, which may share rather than copy. - private sealed class ReadOnlyLeaseHandler : IRespHandler?> - { - public ReadOnlyLease? Parse(ReadOnlySpan response) + ReadOnlyLease? IRespHandler?>.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadLease(); } - } - // ---- one-command shapes; reachable through Inbuilt, deliberately not named above ---- - private static readonly IRespHandler s_digest = new DigestHandler(); - private static readonly IRespHandler s_lcsMatch = new LCSMatchHandler(); - private static readonly IRespHandler> s_incrementInt64 = new IncrementInt64Handler(); - private static readonly IRespHandler> s_incrementDouble = new IncrementDoubleHandler(); - private static readonly IRespHandler> s_nullableInt64Lease = new NullableInt64LeaseHandler(); - private static readonly IRespHandler s_hashEntries = new HashEntryHandler(); - private static readonly IRespHandler s_int64Array = new Int64ArrayHandler(); - private static readonly IRespHandler s_expireResults = new ExpireResultHandler(); - private static readonly IRespHandler s_persistResults = new PersistResultHandler(); - private static readonly IRespHandler s_booleans = new BooleanArrayHandler(); - private static readonly IRespHandler s_nullableDouble = new NullableDoubleHandler(); - private static readonly IRespHandler s_nullableDoubles = new NullableDoubleArrayHandler(); - private static readonly IRespHandler s_sortedSetEntries = new SortedSetEntryArrayHandler(); - private static readonly IRespHandler s_sortedSetEntry = new SortedSetEntryHandler(); - private static readonly IRespHandler s_sortedSetPop = new SortedSetPopHandler(); - - private sealed class DigestHandler : IRespHandler - { - public ValueCondition? Parse(ReadOnlySpan response) + ValueCondition? IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -334,11 +284,8 @@ private sealed class DigestHandler : IRespHandler ? digest : throw new RespException("Unexpected DIGEST reply."); } - } - private sealed class LCSMatchHandler : IRespHandler - { - public LCSMatchResult Parse(ReadOnlySpan response) + LCSMatchResult IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -346,11 +293,8 @@ public LCSMatchResult Parse(ReadOnlySpan response) ? result : throw new RespException("Unexpected LCS IDX reply."); } - } - private sealed class IncrementInt64Handler : IRespHandler> - { - public StringIncrementResult Parse(ReadOnlySpan response) + StringIncrementResult IRespHandler>.Parse(ReadOnlySpan response) { // [value, applied-increment]; under a bound the second is not the one that was asked for var reader = new RespReader(response); @@ -364,11 +308,8 @@ public StringIncrementResult Parse(ReadOnlySpan response) throw new RespException("Unexpected INCREX reply."); } - } - private sealed class NullableInt64LeaseHandler : IRespHandler> - { - public Lease Parse(ReadOnlySpan response) + Lease IRespHandler>.Parse(ReadOnlySpan response) { // BITFIELD's reply: a flat array with one element per sub-operation, nil where // OVERFLOW FAIL skipped one @@ -398,91 +339,44 @@ private sealed class NullableInt64LeaseHandler : IRespHandler> return lease; } - } - private sealed class SingletonValueHandler : IRespHandler - { - public RedisValue Parse(ReadOnlySpan response) - { - var reader = new RespReader(response); - reader.MoveNext(); - if (reader.IsNull) return RedisValue.Null; // the whole reply, not an element of it - reader.MoveNext(); - return reader.IsNull ? RedisValue.Null : reader.ReadRedisValue(); - } - } - - /// - /// The copying form, matching rather than : this - /// exists to serve IDatabase.HashFieldGetLease*, whose signatures say . - /// A sharing singleton would be a sibling, which is a decision for - /// whoever finishes design notes 6.16 rather than one to guess at here. - /// - private sealed class SingletonLeaseHandler : IRespHandler?> - { - public Lease? Parse(ReadOnlySpan response) - { - var reader = new RespReader(response); - reader.MoveNext(); - if (reader.IsNull) return null; - reader.MoveNext(); -#pragma warning disable CS0618 // the copying form is what this contract needs; see the remarks - return RespReaderExtensions.ReadLease(in reader); -#pragma warning restore CS0618 - } - } - - private sealed class HashEntryHandler : IRespHandler - { // RESP2 sends name/value interleaved and RESP3 may send them jagged; the existing processor // already decides between them from the CONTENT rather than from the negotiated protocol, so // reusing it is both less code and the only way the two readers cannot disagree. Resp3 is // passed to enable that detection, not to assert anything about the connection. - private static readonly ResultProcessor.HashEntryArrayProcessor Shape = new(); + private static readonly ResultProcessor.HashEntryArrayProcessor HashEntryShape = new(); - public HashEntry[] Parse(ReadOnlySpan response) + HashEntry[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); - return Shape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) + return HashEntryShape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) ?? Array.Empty(); } - } - private sealed class Int64ArrayHandler : IRespHandler - { - public long[] Parse(ReadOnlySpan response) + long[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadPastArray(static (ref r) => r.ReadInt64(), scalar: true) ?? Array.Empty(); } - } - private sealed class ExpireResultHandler : IRespHandler - { - public ExpireResult[] Parse(ReadOnlySpan response) + ExpireResult[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadPastArray(static (ref r) => (ExpireResult)r.ReadInt64(), scalar: true) ?? Array.Empty(); } - } - private sealed class NullableDoubleHandler : IRespHandler - { - public double? Parse(ReadOnlySpan response) + double? IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.IsNull ? null : reader.ReadDouble(); } - } - private sealed class NullableDoubleArrayHandler : IRespHandler - { - public double?[] Parse(ReadOnlySpan response) + double?[] IRespHandler.Parse(ReadOnlySpan response) { // ZMSCORE replies nil for a member that is not there, so the element type has to be nullable var reader = new RespReader(response); @@ -490,25 +384,19 @@ private sealed class NullableDoubleArrayHandler : IRespHandler return reader.ReadPastArray(static (ref r) => r.IsNull ? (double?)null : r.ReadDouble(), scalar: true) ?? Array.Empty(); } - } - private sealed class SortedSetEntryArrayHandler : IRespHandler - { // as HashEntryHandler: interleaved in RESP2, possibly jagged in RESP3, decided from the content - private static readonly ResultProcessor.SortedSetEntryArrayProcessor Shape = new(); + private static readonly ResultProcessor.SortedSetEntryArrayProcessor SortedSetEntryShape = new(); - public SortedSetEntry[] Parse(ReadOnlySpan response) + SortedSetEntry[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); - return Shape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) + return SortedSetEntryShape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: false, out _, state: null) ?? Array.Empty(); } - } - private sealed class SortedSetEntryHandler : IRespHandler - { - public SortedSetEntry? Parse(ReadOnlySpan response) + SortedSetEntry? IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -516,11 +404,8 @@ private sealed class SortedSetEntryHandler : IRespHandler ? result : throw new RespException("Unexpected sorted-set pop reply."); } - } - private sealed class SortedSetPopHandler : IRespHandler - { - public SortedSetPopResult Parse(ReadOnlySpan response) + SortedSetPopResult IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -528,32 +413,23 @@ public SortedSetPopResult Parse(ReadOnlySpan response) ? result : throw new RespException("Unexpected ZMPOP reply."); } - } - private sealed class BooleanArrayHandler : IRespHandler - { - public bool[] Parse(ReadOnlySpan response) + bool[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadPastArray(static (ref r) => r.ReadBoolean(), scalar: true) ?? Array.Empty(); } - } - private sealed class PersistResultHandler : IRespHandler - { - public PersistResult[] Parse(ReadOnlySpan response) + PersistResult[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); return reader.ReadPastArray(static (ref r) => (PersistResult)r.ReadInt64(), scalar: true) ?? Array.Empty(); } - } - private sealed class IncrementDoubleHandler : IRespHandler> - { - public StringIncrementResult Parse(ReadOnlySpan response) + StringIncrementResult IRespHandler>.Parse(ReadOnlySpan response) { var reader = new RespReader(response); reader.MoveNext(); @@ -567,6 +443,65 @@ public StringIncrementResult Parse(ReadOnlySpan response) throw new RespException("Unexpected INCREX reply."); } } + + private sealed class SuccessHandler : IRespHandler + { + public bool Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); // skips attributes, and throws RespException on an error element + return true; + } + } + + // ---- one-command shapes; reachable through Inbuilt, deliberately not named above ---- + private static readonly IRespHandler s_digest = DefaultHandlers.Instance; + private static readonly IRespHandler s_lcsMatch = DefaultHandlers.Instance; + private static readonly IRespHandler> s_incrementInt64 = DefaultHandlers.Instance; + private static readonly IRespHandler> s_incrementDouble = DefaultHandlers.Instance; + private static readonly IRespHandler> s_nullableInt64Lease = DefaultHandlers.Instance; + private static readonly IRespHandler s_hashEntries = DefaultHandlers.Instance; + private static readonly IRespHandler s_int64Array = DefaultHandlers.Instance; + private static readonly IRespHandler s_expireResults = DefaultHandlers.Instance; + private static readonly IRespHandler s_persistResults = DefaultHandlers.Instance; + private static readonly IRespHandler s_booleans = DefaultHandlers.Instance; + private static readonly IRespHandler s_nullableDouble = DefaultHandlers.Instance; + private static readonly IRespHandler s_nullableDoubles = DefaultHandlers.Instance; + private static readonly IRespHandler s_sortedSetEntries = DefaultHandlers.Instance; + private static readonly IRespHandler s_sortedSetEntry = DefaultHandlers.Instance; + private static readonly IRespHandler s_sortedSetPop = DefaultHandlers.Instance; + + private sealed class SingletonValueHandler : IRespHandler + { + public RedisValue Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsNull) return RedisValue.Null; // the whole reply, not an element of it + reader.MoveNext(); + return reader.IsNull ? RedisValue.Null : reader.ReadRedisValue(); + } + } + + /// + /// The copying form, matching rather than : this + /// exists to serve IDatabase.HashFieldGetLease*, whose signatures say . + /// A sharing singleton would be a sibling, which is a decision for + /// whoever finishes design notes 6.16 rather than one to guess at here. + /// + private sealed class SingletonLeaseHandler : IRespHandler?> + { + public Lease? Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + if (reader.IsNull) return null; + reader.MoveNext(); +#pragma warning disable CS0618 // the copying form is what this contract needs; see the remarks + return RespReaderExtensions.ReadLease(in reader); +#pragma warning restore CS0618 + } + } } /// diff --git a/tests/StackExchange.Redis.Tests/RespHandlerRegistryTests.cs b/tests/StackExchange.Redis.Tests/RespHandlerRegistryTests.cs new file mode 100644 index 000000000..c5a9fe37f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespHandlerRegistryTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Default handler lookup: implementing the interface is the registration. +/// +/// +/// Replaces a typeof(T) == ladder that had grown to 25 entries and that nobody was obliged to +/// extend. These pin the properties that make the replacement worth having. +/// +public class RespHandlerRegistryTests +{ + private static readonly Type Defaults = typeof(RespHandlers) + .GetNestedTypes(System.Reflection.BindingFlags.NonPublic) + .Single(t => t.Name == "DefaultHandlers"); + + [Fact] + public void EveryDefaultIsRegisteredByImplementingTheInterface() + { + // the registration IS the interface list, so this is the whole registry + var registered = Defaults.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition().Name.StartsWith("IResp", StringComparison.Ordinal)) + .ToArray(); + + Assert.NotEmpty(registered); + + // and each one really resolves through the public entry point + foreach (var iface in registered.Where(i => i.GetGenericTypeDefinition() == typeof(IRespHandler<>))) + { + var t = iface.GetGenericArguments()[0]; + var inbuilt = typeof(RespHandlers).GetNestedType("Inbuilt`1", System.Reflection.BindingFlags.NonPublic)! + .MakeGenericType(t); + var handler = inbuilt.GetField("Handler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .GetValue(null); + + Assert.True(handler is not null, $"no default resolved for {t.Name}, yet DefaultHandlers implements it"); + } + } + + [Fact] + public void TwoDefaultsForOneTypeWouldBeACompileError() + { + // not assertable directly - it is a compile error, which is the point. What IS assertable is the + // consequence: types with more than one handler keep exactly one on the defaults object, and the + // alternates stay separate and are named explicitly by whoever wants them. + Assert.NotSame(RespHandlers.Boolean, RespHandlers.Success); + Assert.NotSame(RespHandlers.Value, RespHandlers.SingletonValue); + Assert.NotSame(RespHandlers.Lease, RespHandlers.SingletonLease); + + // the defaults are the ones on the singleton; the alternates are not + Assert.Equal(Defaults, RespHandlers.Boolean.GetType()); + Assert.Equal(Defaults, RespHandlers.Value.GetType()); + Assert.NotEqual(Defaults, RespHandlers.Success.GetType()); + Assert.NotEqual(Defaults, RespHandlers.SingletonValue.GetType()); + } + + [Fact] + public void AnUnregisteredTypeSaysSoRatherThanGuessing() + { + var ex = Assert.Throws(() => RespHandlers.Inbuilt.Require()); + Assert.Contains("Guid", ex.Message); + } + + [Fact] + public void InvarianceStopsABaseTypeBorrowingADerivedHandler() + { + // IRespHandler must stay invariant. Were it covariant, `as IRespHandler` would match the + // string handler and SendAsync would silently get a string parser - the one hazard this + // lookup has that a typeof ladder did not. + Assert.False(typeof(IRespHandler<>).GetGenericArguments()[0].GenericParameterAttributes + .HasFlag(System.Reflection.GenericParameterAttributes.Covariant)); + + Assert.NotNull(RespHandlers.Inbuilt.Handler); + Assert.Null(RespHandlers.Inbuilt.Handler); + } + + [Fact] + public async Task TheResolvedHandlersStillParse() + { + // a spot check that the bodies survived being moved onto one object + var executor = new RespHandlerRegistryExecutor("$5\r\nhello\r\n", ":42\r\n", "+OK\r\n"); + var context = new RespContext().WithExecutor(executor); + + Assert.Equal("hello", await context.SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.None)); + Assert.Equal(42, await context.SendAsync($"{RedisCommand.INCR}{(RedisKey)"k"}", CommandFlags.None)); + Assert.True(await context.SendAsync($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}", CommandFlags.None)); + } +} + +internal sealed class RespHandlerRegistryExecutor(params string[] replies) : IRespExecutor +{ + private int _next; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + => RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + + public ValueTask SendAsync(RespRequest request, System.Threading.CancellationToken cancellationToken = default) + => new(Send(request)); +} From 30db4ec641de7419dcaba26e418dcd34aea0dc42 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 19:31:26 +0100 Subject: [PATCH 123/360] Restore the SWR queue entry, deleted by accident Removing the completed handler-registry item sliced from its heading to the next one, and stale-while-revalidate sat between them. Restored with the decisions that were already made - the two thresholds, where the once-only flag lives, the read-your-own-writes carve-out for invalidation-SWR, measuring the window from first notice, and the cap for the hot-written-key case - so none of it needs re-deriving. Caught only because it was asked about, which is an argument for the queue being a file rather than a conversation. --- design/interpolated-resp-writer.queue.md | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 64ec09410..5a2759cb8 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -18,6 +18,39 @@ a line saying why, because "we decided not to" is worth as much as "we did". mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. +- [ ] **Stale-while-revalidate** (§6.15). Serve the old value while a refresh runs, so the window in which + a stampede is even possible mostly stops existing. Two thresholds instead of one: + + | age | behaviour | + |---|---| + | `< soft` | fresh hit | + | `soft ≤ age < hard` | **serve stale**, and trigger a refresh, once | + | `≥ hard` | miss | + + **Prerequisites are all in now:** single-flight is the same interlock the "refresh once" needs + (`934e8d2d`), `CachePolicy` already carries the lifetime and is where the soft threshold goes + (`9cf7da77`), and entries already record when they were filled. + + **The refresh needs no factory** — the cache key *is* the request, so refreshing means re-sending it. + No delegate, no captured state, nothing of the caller's retained, and handler-agnostic because the + cache stores raw bytes. That is the thing HybridCache's `(TState, Func)` shape exists + to work around. + + Details already decided, so they do not need re-deriving: + + - The once-only flag lives on the **shared entry**; the thresholds are **per-context**. Whoever + crosses their own soft bar first triggers a refresh everyone benefits from. + - The flag must clear on **failure** as well as success, with backoff, or one failing server pins an + entry stale until hard expiry. + - **Invalidation-SWR is separate and opt-in**: serving through an invalidation is deliberately + serving data the server said is wrong. It must **not** apply to invalidations we caused ourselves — + that is read-your-own-writes, and it is reported as corruption, not staleness. The carve-out needs + no bookkeeping, because local invalidation happens before the echoed push arrives. + - Measure the invalidation window from **first notice**, not from the invalidation: the latter needs a + timestamp on the key node, which is on the ~5-6ns allocation-free `OnInvalidate` path. + - Needs an absolute **cap**. On a hot-written key every refresh is invalidated in flight, `AllValid` + correctly refuses the store, and the entry would serve stale indefinitely. + - [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and From 253cc2e4748235ae45fcf2d6f2d6c76c080ff6c5 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 19:41:26 +0100 Subject: [PATCH 124/360] Stale-while-revalidate: serve the old value, refresh behind it CachePolicy.RefreshAfter is the soft threshold, TimeToLive the hard one. A read between them is served AND claims a refresh, which runs on the thread pool while the caller already has an answer - so an entry never goes from good to gone in one step, which is the moment every concurrent reader of a hot key misses at once. The refresh needs nothing configured: the cache key IS the request, so refreshing means re-sending it. No factory, no captured state, nothing of the caller's retained, and handler-agnostic because the cache stores raw bytes. That is what HybridCache's (TState, Func) shape exists to work around, and here it falls out of the design rather than being built. Off by default. Serving a value already known to be old is a choice about correctness, not a tuning knob. Three things that were not obvious until it ran: - the claim has to be ATOMIC with the lookup. TryGet decides "this is ageing" and "you are the one who will fix it" together, and tells exactly one caller; deciding those separately lets every reader past the threshold decide both, which is the stampede in different clothing. - a refresh must REPLACE, not add. TryComplete used TryAdd - right for a first fill, where losing the race means someone answered first and their answer is as good - which made every refresh a silent no-op that still counted as a redundant fill. - swap in place, not remove-then-add. TryRemove returns the value but not the stored KEY, and the key holds a retained request; removing strands that reference, and disposing our own copy instead releases the wrong one. TryUpdate leaves the dictionary's key alone, so only the superseded reply needs releasing - watched by a reference-count test. A refresh takes no in-flight registration: it is not something to wait for. The entry is still being served, so a concurrent miss is asking a different question and should fetch rather than queue. Mutation-tested three ways: no once-only claim, refresh adding instead of replacing, and the superseded reply never released. Also makes RespTrackingTests.TheKeyBytesTheServerSendsAreTheOnesWeRecorded assert its actual claim - that the server named the bytes we wrote - by recording the key names the push carried, rather than inferring it from cache state. Any concurrent FLUSHDB sends an unfilterable flush push that can invalidate the entry before it is stored, which made the old assertion intermittent. --- design/interpolated-resp-writer.md | 40 ++++ design/interpolated-resp-writer.queue.md | 40 +--- .../Interpolated/CachePolicy.cs | 31 +++ .../Interpolated/RespClientCache.cs | 170 +++++++++++++- .../Interpolated/RespExecutor.cs | 84 ++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 6 + .../Helpers/TrackingExecutor.cs | 11 + .../RespStaleWhileRevalidateTests.cs | 208 ++++++++++++++++++ .../RespTrackingTests.cs | 14 +- 9 files changed, 562 insertions(+), 42 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 86afebb93..4123f33fe 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2142,6 +2142,46 @@ than a compromise: whoever crosses their own soft bar first triggers a refresh e flag must clear on failure as well as success, with backoff — otherwise one failing server leaves the entry pinned stale until hard expiry. +#### Built: expiry-based SWR + +`CachePolicy.RefreshAfter` is the soft threshold; `TimeToLive` remains the hard one. A read between them +is **served** and **claims a refresh**, which runs on the thread pool while the caller already has an +answer. + +**The refresh needs nothing configured.** The cache key *is* the request, so refreshing means re-sending +it — no factory, no captured state, nothing of the caller's retained, and handler-agnostic because the +cache stores raw bytes. That is the thing `HybridCache`'s `(TState, Func)` shape exists to +work around, and it falls out of §6 rather than being designed. + +**Off by default** (`RefreshAfter = Zero`). Serving a value already known to be old is a choice about +correctness, not a tuning knob, so it is made rather than inherited. A threshold beyond the lifetime means +the same thing as off — it could never be crossed. + +Three things this needed that were not obvious until it ran: + +- **The claim must be atomic with the lookup.** `TryGet` decides "this is ageing" *and* "you are the one + who will fix it" in one step, and tells exactly one caller. Deciding those separately lets every reader + past the threshold decide both, which is the stampede again in different clothing. The flag lives on the + shared entry while the thresholds are per-context, so whoever crosses their own bar first starts a + refresh everyone benefits from. +- **A refresh must REPLACE, not add.** `TryComplete` used `TryAdd`, which is right for a first fill — + losing that race means somebody answered the same question first and their answer is as good — but makes + every refresh a silent no-op that still counts as a redundant fill. It now swaps in place when the fill + says it replaces. +- **Swap in place rather than remove-then-add.** `TryRemove` hands back the *value* but not the stored + *key*, and the key holds a retained request of its own; removing would strand that reference, and + disposing our own copy instead would release the wrong one. `TryUpdate` leaves the dictionary's key + untouched, so only the superseded reply needs releasing — which it does, and there is a test watching the + reference count to prove it. + +A refresh takes **no in-flight registration**: it is not something anybody should wait for. The entry is +still being served, so a concurrent miss for the same request is asking a different question — it has +nothing, and should fetch rather than queue behind a nicety. + +`Refreshes` counts them. Read it against `Expired`: refreshes rising while expiries stay near zero is the +shape you want — entries renewed before anyone had to wait. Expiries rising alongside means the window is +too narrow to cover the fetch. + #### Stale-while-revalidate on invalidation Also possible, under "you cannot prove the order, so any order is valid" — but **only for third-party diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 5a2759cb8..db56f5606 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -18,38 +18,11 @@ a line saying why, because "we decided not to" is worth as much as "we did". mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. -- [ ] **Stale-while-revalidate** (§6.15). Serve the old value while a refresh runs, so the window in which - a stampede is even possible mostly stops existing. Two thresholds instead of one: - - | age | behaviour | - |---|---| - | `< soft` | fresh hit | - | `soft ≤ age < hard` | **serve stale**, and trigger a refresh, once | - | `≥ hard` | miss | - - **Prerequisites are all in now:** single-flight is the same interlock the "refresh once" needs - (`934e8d2d`), `CachePolicy` already carries the lifetime and is where the soft threshold goes - (`9cf7da77`), and entries already record when they were filled. - - **The refresh needs no factory** — the cache key *is* the request, so refreshing means re-sending it. - No delegate, no captured state, nothing of the caller's retained, and handler-agnostic because the - cache stores raw bytes. That is the thing HybridCache's `(TState, Func)` shape exists - to work around. - - Details already decided, so they do not need re-deriving: - - - The once-only flag lives on the **shared entry**; the thresholds are **per-context**. Whoever - crosses their own soft bar first triggers a refresh everyone benefits from. - - The flag must clear on **failure** as well as success, with backoff, or one failing server pins an - entry stale until hard expiry. - - **Invalidation-SWR is separate and opt-in**: serving through an invalidation is deliberately - serving data the server said is wrong. It must **not** apply to invalidations we caused ourselves — - that is read-your-own-writes, and it is reported as corruption, not staleness. The carve-out needs - no bookkeeping, because local invalidation happens before the echoed push arrives. - - Measure the invalidation window from **first notice**, not from the invalidation: the latter needs a - timestamp on the key node, which is on the ~5-6ns allocation-free `OnInvalidate` path. - - Needs an absolute **cap**. On a hot-written key every refresh is invalidated in flight, `AllValid` - correctly refuses the store, and the entry would serve stale indefinitely. +- [ ] **Invalidation-based SWR** (§6.15), the remaining half. Expiry-based SWR is built. This one is + opt-in and deliberately separate: serving through an invalidation means serving data the server has + said is wrong. Must **not** apply to invalidations we caused ourselves (read-your-own-writes); measure + the window from first notice rather than from the invalidation; and it needs the absolute cap, since a + hot-written key would otherwise serve stale indefinitely as each refresh is invalidated in flight. - [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in @@ -122,7 +95,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Ad-hoc `ExecuteAsync` returning `RespResult`, on the context and on `IRespTarget`; `ExecuteResp` wired through `TransitionalDatabase` — `30d28d70` - [x] `RespResult` shares the reply buffer instead of copying it — `696a5c3f` -- [x] Interface-based default handler lookup; `IRespHandler` made invariant — this change +- [x] Interface-based default handler lookup; `IRespHandler` made invariant — `a539a538` +- [x] Stale-while-revalidate on expiry, with background refresh — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/CachePolicy.cs b/src/StackExchange.Redis/Interpolated/CachePolicy.cs index 14abbe588..ec99cbc24 100644 --- a/src/StackExchange.Redis/Interpolated/CachePolicy.cs +++ b/src/StackExchange.Redis/Interpolated/CachePolicy.cs @@ -52,6 +52,37 @@ public sealed class CachePolicy /// Whether this policy permits caching at all. public bool Enabled { get; init; } = true; + /// + /// How old an entry may get before a read refreshes it in the background, while still being served. + /// + /// + /// + /// Stale-while-revalidate. Without it an entry goes from "good" to "gone" in one step, and every + /// concurrent reader of a hot key misses at the same instant - the stampede this is here to prevent. + /// With it there are two thresholds: below this, a hit is simply fresh; between this and + /// the old value is still served and a refresh is started; past + /// it is a miss like any other. + /// + /// + /// Off by default. Serving a value already known to be old is a choice about correctness, not + /// a tuning knob, so it should be made rather than inherited. or greater + /// than both mean "never refresh early" - the latter because a threshold + /// beyond the lifetime can never be crossed. + /// + /// + /// The refresh costs no configuration of its own: the cache key is the request, so refreshing + /// an entry means re-sending it. Nothing has to be handed a factory, and nothing of the caller's is + /// retained to make it possible. + /// + /// + public TimeSpan RefreshAfter { get; init; } = TimeSpan.Zero; + + /// Whether this policy asks for background refresh at all. + internal bool RefreshesEarly => RefreshAfter > TimeSpan.Zero && RefreshAfter < TimeToLive; + + /// as a tick count. + internal long RefreshAfterTicks => ToTicks(RefreshAfter); + /// as a tick count. /// /// rather than Environment.TickCount64, which does not diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 17150d098..cc86ae613 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -57,6 +57,7 @@ public sealed class RespClientCache : IDisposable private long _refusedError; private long _coalesced; private long _expired; + private long _refreshes; /// Create a cache. /// How entries behave; when null. @@ -75,6 +76,14 @@ public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) /// How entries in this cache behave. public CachePolicy Policy { get; } + /// Background refreshes started, because an entry was ageing but still servable. + /// + /// The stampedes that never formed. Compare with : refreshes rising while + /// expiries stay near zero is the shape you want - entries being renewed before anybody had to wait + /// for one. Expiries rising alongside means the refresh window is too narrow to cover the fetch. + /// + public long Refreshes => Volatile.Read(ref _refreshes); + /// Hits refused because the entry had outlived its lifetime. /// /// Distinct from an invalidation: nobody told us this was wrong, we simply stopped trusting it. A @@ -197,7 +206,31 @@ public bool TryGet(in RespRequest frame, int database, [NotNullWhen(true)] out R /// /// public bool TryGet(in RespRequest frame, int database, long maxAgeTicks, [NotNullWhen(true)] out RespPayload? payload) + => TryGet(in frame, database, maxAgeTicks, out payload, out _); + + /// + /// The rendered request. + /// The database the request ran against. + /// The caller's own freshness requirement. + /// The cached reply, retained. + /// + /// true if this caller has claimed the job of refreshing an ageing entry, and must + /// now do it. At most one caller is told this per refresh. + /// + /// + /// The claim is made here rather than by the caller because it has to be atomic with the lookup: + /// deciding "this is old" and "I will be the one to fix it" in separate steps lets every reader past + /// the threshold decide both, which is the stampede again wearing a different hat. + /// + public bool TryGet( + in RespRequest frame, + int database, + long maxAgeTicks, + [NotNullWhen(true)] out RespPayload? payload, + out bool shouldRefresh) { + shouldRefresh = false; + if (_entries.TryGetValue(new EntryKey(frame, database), out var entry) && entry.IsValid && entry.Payload.TryRetain()) @@ -209,6 +242,17 @@ public bool TryGet(in RespRequest frame, int database, long maxAgeTicks, [NotNul var limit = Math.Min(Policy.TimeToLiveTicks, maxAgeTicks); if (!CachePolicy.IsOlderThan(entry.FilledAt, limit)) { + // served either way; the only question is whether this reader also goes and gets a + // newer one. Note the soft threshold is the POLICY's, not the caller's: a caller + // asking for fresher than it gets a miss, which is a stronger answer than a refresh. + if (Policy.RefreshesEarly + && CachePolicy.IsOlderThan(entry.FilledAt, Policy.RefreshAfterTicks) + && entry.TryClaimRefresh()) + { + Interlocked.Increment(ref _refreshes); + shouldRefresh = true; + } + payload = entry.Payload; return true; } @@ -223,6 +267,75 @@ public bool TryGet(in RespRequest frame, int database, long maxAgeTicks, [NotNul return false; } + /// + /// Begin a fill for a background refresh, from a request that has already been rendered. + /// + /// The request to refresh; borrowed, and retained internally if this succeeds. + /// The database it runs against. + /// The fill to complete once the reply arrives. + /// + /// The ordinary takes a + /// freshly rendered frame and detaches it. A refresh has no frame to render - the whole point + /// is that the cache key already is the request - so this retains rather than detaches, and + /// ownership of the caller's copy is unaffected. + /// + /// Key generations are captured here, before the refresh is sent, exactly as for a first fill: a + /// write landing while the refresh is in flight must lose, not win. + /// + /// + public bool TryBeginRefresh(in RespRequest request, int database, out RespFill fill) + { + if (!IsCacheable(request.Flags)) + { + Interlocked.Increment(ref _refusedByFlags); + fill = default; + return false; + } + + var keyCount = request.KeyCount; + if (keyCount <= 0) + { + Interlocked.Increment(ref _refusedNoKeys); + fill = default; + return false; + } + + Span ranges = keyCount <= 16 ? stackalloc KeyRange[16] : new KeyRange[keyCount]; + var count = request.TryGetKeys(ranges); + if (count < 0 || !request.TryRetain(out var key)) + { + fill = default; + return false; + } + + var deps = count == 0 ? [] : new Dependency[count]; + for (var i = 0; i < count; i++) + { + var node = _keys.GetOrAdd(request.GetKey(ranges[i]), out var generation); + deps[i] = new Dependency(node, generation); + } + + // no in-flight registration: a refresh is not something anybody should wait for. The entry is + // still being served, so a concurrent miss wanting this request is asking a different question - + // it has nothing yet, and should fetch rather than queue behind a nicety. + fill = new RespFill(key, database, deps, this, slot: null, replaces: true); + return true; + } + + /// Give back a refresh claim, so a later read can try again. + /// The request whose entry was being refreshed. + /// The database it ran against. + /// + /// Call on every outcome, success or failure. A refresh that completes replaces the entry, so + /// the claim goes with the old one; a refresh that throws must hand the claim back, or the entry is + /// pinned stale until its hard expiry while still being served - silent, and exactly the state this + /// feature exists to avoid. + /// + public void EndRefresh(in RespRequest frame, int database) + { + if (_entries.TryGetValue(new EntryKey(frame, database), out var entry)) entry.ReleaseRefreshClaim(); + } + /// /// Begin a fill, capturing the generations of the frame's keys. Call this before sending the /// command, not when the reply arrives. @@ -455,7 +568,25 @@ private bool TryCompleteCore(in RespFill fill, RespPayload response) return false; } - if (_entries.TryAdd(new EntryKey(stored, fill.Database), new Entry(response, fill.Dependencies))) + var entryKey = new EntryKey(stored, fill.Database); + + // A refresh REPLACES the entry it was started for. Swap the value in place rather than + // remove-then-add: the dictionary keeps the key object it already has, so its retained request + // stays owned by the dictionary. TryRemove hands back the value but NOT the stored key, so + // removing would strand that key's reference - and disposing our own copy instead would release + // the wrong one. + if (fill.Replaces + && _entries.TryGetValue(entryKey, out var previous) + && _entries.TryUpdate(entryKey, new Entry(response, fill.Dependencies), previous)) + { + previous.Payload.Dispose(); // the superseded reply + stored.Dispose(); // our key copy was spare; the dictionary kept its own + fill.Key.Dispose(); + Interlocked.Increment(ref _stored); + return true; + } + + if (_entries.TryAdd(entryKey, new Entry(response, fill.Dependencies))) { fill.Key.Dispose(); // the dictionary holds its own references now Interlocked.Increment(ref _stored); @@ -648,12 +779,35 @@ private sealed class InFlight(Dependency[] dependencies) private sealed class Entry(RespPayload payload, Dependency[] dependencies) { + private int _refreshing; + internal RespPayload Payload { get; } = payload; /// When this entry was filled, for expiry. See . internal long FilledAt { get; } = Stopwatch.GetTimestamp(); internal bool IsValid => Dependency.AllValid(dependencies); + + /// + /// Claim the right to refresh this entry, once. + /// + /// + /// The flag lives on the shared entry while the thresholds that lead here are + /// per-context, and that is deliberate: whoever crosses their own soft bar first starts a + /// refresh everyone benefits from. Without the claim, every concurrent reader past the + /// threshold would start one - the background refresh would itself be the stampede it exists to + /// prevent. + /// + internal bool TryClaimRefresh() => Interlocked.CompareExchange(ref _refreshing, 1, 0) == 0; + + /// + /// Give the claim back, so a later read can try again. + /// + /// + /// Must happen on failure as well as success, or one failed refresh pins the entry stale until + /// its hard expiry - the entry is still being served the whole time, so the damage is silent. + /// + internal void ReleaseRefreshClaim() => Volatile.Write(ref _refreshing, 0); } /// The frame AND the database; see the note on database asymmetry in the type remarks. @@ -674,15 +828,27 @@ private readonly struct EntryKey(RespRequest frame, int database) : IEquatable + /// Whether completing this fill should replace an entry that is already there. + /// + /// + /// A first fill must not overwrite: losing that race means somebody else answered the same + /// question first, and their answer is as good as ours. A refresh is the opposite - the + /// entry it is replacing is the very one it was started for, so add-only would make every + /// refresh a no-op that still counted as a redundant fill. + /// + internal bool Replaces { get; } + internal RespRequest Key { get; } internal int Database { get; } diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index 76e6bdf03..c6fcd6db6 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -172,7 +172,7 @@ public static TResult Send( // not get a cached answer either, not merely that this reply is not kept if (cache is not null && cache.PermitsCaching(flags)) { - if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, out var cached)) return cached; + if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, flags, out var cached)) return cached; // NOTE: no in-flight wait here. Coalescing means waiting on someone else's Task, and doing // that from a synchronous caller is the sync-over-async problem this design avoids @@ -258,7 +258,7 @@ public static ValueTask SendAsync( if (cache is not null && cache.PermitsCaching(flags)) { - if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, out var cached)) + if (TryServeFromCache(executor, ref request, handler, cache, context.MaxCacheAgeTicks, flags, out var cached)) { return new ValueTask(cached); } @@ -382,15 +382,26 @@ private static bool TryServeFromCache( IRespHandler handler, RespClientCache cache, long maxAgeTicks, + CommandFlags flags, [MaybeNullWhen(false)] out TResult result) { - if (!cache.TryGet(request.AsLookupKey(), executor.Database, maxAgeTicks, out var hit)) + if (!cache.TryGet(request.AsLookupKey(), executor.Database, maxAgeTicks, out var hit, out var refresh)) { result = default; return false; } - request.Dispose(); + if (refresh) + { + // this caller claimed the refresh, so the request cannot simply be dropped: it IS the thing + // that needs re-sending. Detach hands ownership to the background send, which disposes it. + StartRefresh(executor, request.Detach(flags), cache); + } + else + { + request.Dispose(); + } + try { result = Parse(handler, hit); @@ -402,6 +413,71 @@ private static bool TryServeFromCache( } } + /// + /// Re-fetch an ageing entry in the background, while its old value is still being served. + /// + /// + /// + /// No factory, no captured state. Refreshing means re-sending the request, because the cache + /// key is the request - so this needs nothing from the caller and retains nothing of theirs. + /// It is also handler-agnostic: the cache stores the raw reply, so a refresh does not need to know + /// what anybody intended to turn the bytes into. + /// + /// + /// Deliberately not awaited. The caller already has an answer - that is the whole point of serving + /// stale - so the refresh must not make them wait for a better one. Which means nothing observes the + /// task, and every failure has to be swallowed here: an unobserved faulted task is a process-level + /// event, and a refresh failing is a normal occurrence rather than an error. + /// + /// + /// The claim is handed back on every path. A refresh that throws and keeps its claim would + /// pin the entry stale until its hard expiry, still serving the whole time. + /// + /// + private static void StartRefresh( + IRespExecutor executor, + RespRequest request, + RespClientCache cache) + { + _ = Task.Run(async () => + { + try + { + if (!cache.TryBeginRefresh(request, executor.Database, out var fill)) + { + return; + } + + RespPayload? response = null; + try + { + response = await executor.SendAsync(fill.Key).ConfigureAwait(false); + if (response is not null) cache.TryComplete(fill, response); + else fill.Abandon(); + } + catch + { + fill.Abandon(); + throw; + } + finally + { + response?.Release(); + } + } + catch + { + // a refresh is best-effort by construction: the caller already has an answer, and the + // entry expires on its own if this keeps failing + } + finally + { + cache.EndRefresh(request, executor.Database); + request.Dispose(); + } + }); + } + private static async ValueTask AwaitFill( IRespExecutor executor, RespClientCache.RespFill fill, diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index deb91e735..a18912ae1 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -347,3 +347,9 @@ static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.Re static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? [SER010]StackExchange.Redis.Interpolated.RespContext.ExecuteAsync(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.ExecuteAsync(this StackExchange.Redis.Interpolated.IRespTarget! target, string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.EndRefresh(in StackExchange.Redis.Interpolated.RespRequest frame, int database) -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Refreshes.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload, out bool shouldRefresh) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginRefresh(in StackExchange.Redis.Interpolated.RespRequest request, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool diff --git a/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs index df095db87..9540cdfeb 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/TrackingExecutor.cs @@ -46,6 +46,16 @@ internal sealed class TrackingExecutor : IRespExecutor, IDisposable /// Keys named across all those pushes; a single push can carry several. internal int KeysInvalidated => Volatile.Read(ref _keysInvalidated); + /// + /// The key names the server actually sent, so a test can compare them with what it wrote. + /// + /// + /// Decoded as UTF-8 for comparison only - the cache itself never turns them into strings. Recorded + /// because asserting on cache state instead is not reliable against a shared server: any concurrent + /// FLUSHDB sends an unfilterable flush push, which can invalidate an entry before it is even stored. + /// + internal System.Collections.Generic.List InvalidatedKeys { get; } = []; + /// Flush pushes received (the null payload). internal int Flushes => Volatile.Read(ref _flushes); @@ -271,6 +281,7 @@ private bool TryInvalidate(ReadOnlySpan frame) { if (!reader.TryMoveNext(false) || !reader.TryGetSpan(out var key)) break; Interlocked.Increment(ref _keysInvalidated); + lock (InvalidatedKeys) InvalidatedKeys.Add(Encoding.UTF8.GetString(key.ToArray())); _cache.OnInvalidate(key); // allocation-free: the key never leaves this span } diff --git a/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs new file mode 100644 index 000000000..ab0257a01 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Stale-while-revalidate: an ageing entry is served and refreshed, so it never goes from good to +/// gone in one step - which is the moment every concurrent reader of a hot key misses at once. +/// +/// +/// See design notes 6.15. The refresh needs no factory: the cache key is the request. +/// +public class RespStaleWhileRevalidateTests +{ + private sealed class CountingExecutor(params string[] replies) : IRespExecutor + { + private int _next; + private int _sends; + + internal int Sends => Volatile.Read(ref _sends); + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Interlocked.Increment(ref _sends); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private const CommandFlags Readable = CommandFlags.CommandRetryReadOnly; + + private static ValueTask Get(RespContext context) + => context.SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", Readable); + + /// The refresh runs on the thread pool, so wait for it rather than guessing at a delay. + private static async Task WaitFor(Func condition, int millis = 5000) + { + var watch = Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < millis) + { + if (condition()) return true; + await Task.Delay(10); + } + + return condition(); + } + + private static RespContext Context(CountingExecutor executor, RespClientCache cache) + => new RespContext().WithExecutor(executor).WithCache(cache); + + [Fact] + public async Task AnAgeingEntryIsServedAndRefreshed() + { + using var cache = new RespClientCache(new CachePolicy + { + RefreshAfter = TimeSpan.FromMilliseconds(60), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal(1, executor.Sends); + + await Task.Delay(150); // now past the soft threshold, nowhere near the hard one + + // served from cache - the caller does NOT wait for the refresh, which is the whole point + Assert.Equal("a", await Get(context)); + + // ...and a refresh was started behind it + Assert.True(await WaitFor(() => executor.Sends == 2), "no background refresh was started"); + Assert.Equal(1, cache.Refreshes); + + // and the refreshed value is what the next read sees, without anybody having waited for it + Assert.True(await WaitFor(() => cache.Stored == 2), "the refresh never landed in the cache"); + Assert.Equal("b", await Get(context)); + Assert.Equal(2, executor.Sends); // still just the original and the one refresh + } + + [Fact] + public async Task OnlyOneReaderRefreshes() + { + // without the claim, every reader past the threshold starts a refresh - the background work would + // be the stampede it exists to prevent + using var cache = new RespClientCache(new CachePolicy + { + RefreshAfter = TimeSpan.FromMilliseconds(50), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + await Task.Delay(120); + + for (var i = 0; i < 10; i++) Assert.Equal("a", await Get(context)); + + await Task.Delay(200); + Assert.Equal(1, cache.Refreshes); // ten readers, one refresh + Assert.True(executor.Sends <= 2, $"expected 1 original + at most 1 refresh, saw {executor.Sends}"); + } + + [Fact] + public async Task AFreshEntryIsNotRefreshed() + { + using var cache = new RespClientCache(new CachePolicy + { + RefreshAfter = TimeSpan.FromMinutes(1), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal("a", await Get(context)); + + await Task.Delay(100); + Assert.Equal(1, executor.Sends); + Assert.Equal(0, cache.Refreshes); + } + + [Fact] + public async Task RefreshIsOffByDefault() + { + // serving a value already known to be old is a decision, not an inherited default + Assert.Equal(TimeSpan.Zero, CachePolicy.Default.RefreshAfter); + + using var cache = new RespClientCache(); + var executor = new CountingExecutor("$1\r\na\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + await Task.Delay(100); + Assert.Equal("a", await Get(context)); + + await Task.Delay(100); + Assert.Equal(1, executor.Sends); + Assert.Equal(0, cache.Refreshes); + } + + [Fact] + public async Task ReplacingAnEntryReleasesTheSupersededReply() + { + // the refresh swaps the value in place, and the reply it displaced holds a pooled buffer. Leaking + // that reference would be invisible - the cache keeps working, it just never gives the buffer back. + using var cache = new RespClientCache(new CachePolicy + { + RefreshAfter = TimeSpan.FromMilliseconds(50), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + + // hold our own reference to the first reply so we can watch what the cache does with its one + Assert.True(cache.TryGet(RenderKey(context), 0, long.MaxValue, out var firstReply)); + Assert.Equal(2, firstReply!.RefCount); // the cache holds one, TryGet retained another for us + + await Task.Delay(120); + Assert.Equal("a", await Get(context)); // stale serve, refresh started + Assert.True(await WaitFor(() => cache.Stored == 2), "the refresh never landed"); + + // the cache has let go of the old reply; only our own reference keeps it alive + Assert.True(await WaitFor(() => firstReply.RefCount == 1), $"superseded reply still at {firstReply.RefCount}"); + + firstReply.Release(); + Assert.Equal("b", await Get(context)); + Assert.Equal(1, cache.Count); // replaced, not duplicated + } + + /// The same rendered key the surface would produce, for poking the cache directly. + private static RespRequest RenderKey(RespContext context) + { + var handler = context.Compose($"{RedisCommand.GET}{(RedisKey)"k"}"); + var frame = handler.Complete(); + return frame.Detach(Readable); + } + + [Fact] + public async Task AThresholdBeyondTheLifetimeNeverFires() + { + // it could never be crossed: the entry expires first. Treated as "off" rather than as a puzzle. + using var cache = new RespClientCache(new CachePolicy + { + RefreshAfter = TimeSpan.FromMinutes(10), + TimeToLive = TimeSpan.FromMilliseconds(80), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + await Task.Delay(200); + + Assert.Equal("b", await Get(context)); // a plain expiry, not a stale serve + Assert.Equal(0, cache.Refreshes); + Assert.True(cache.Expired > 0); + } +} diff --git a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs index aacd1200f..9e7d472fc 100644 --- a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs +++ b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs @@ -58,6 +58,11 @@ await WaitFor(() => executor.Invalidations > before), await SettleAsync(executor); } + private static bool Contains(TrackingExecutor executor, string key) + { + lock (executor.InvalidatedKeys) return executor.InvalidatedKeys.Contains(key); + } + private static async Task WaitFor(Func condition, int millis = 2000) { var watch = Stopwatch.StartNew(); @@ -148,10 +153,13 @@ public async Task TheKeyBytesTheServerSendsAreTheOnesWeRecorded() await using var other = Create(); await other.GetDatabase().StringSetAsync(key, "changed"); - Assert.True(await WaitFor(() => executor.KeysInvalidated > 0), "no invalidation arrived at all"); + // The real assertion, made directly: the server named the same bytes we wrote. Comparing the key + // it sent beats inspecting cache state, which is not reliable on a shared server - any concurrent + // FLUSHDB sends an unfilterable flush push that can invalidate our entry before it is even stored. + Assert.True( + await WaitFor(() => Contains(executor, key)), + "the server never named this key - its bytes did not match ours"); - // the real assertion: the server's key bytes matched ours, so the stamp landed on OUR entry - Assert.Equal(1, cache.Sweep()); Assert.Equal("changed", await context.Strings.Get(key)); } From 6b94d588da6e4e16ee1435e6c521dfa890ecabbe Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 20:38:08 +0100 Subject: [PATCH 125/360] Invalidation grace period: bridge the burst, do not resurrect The other half of SWR, and the more valuable one. Age is staggered across entries; an invalidation lands for EVERY reader of a key at the same instant, which is the thundering herd exactly, and no amount of time-based smoothing helps because the trigger was not time. CachePolicy.InvalidationGracePeriod moves the entry's hard expiry to "now plus this", measured FROM THE INVALIDATION. A key under constant access bridges the burst; a key nobody is reading simply expires, because nobody arrives inside the window. The first version measured from first notice instead, which is cheaper - no timestamp needed on the invalidation path - and wrong: it would serve a key invalidated an hour ago to whoever read it next, resurrecting something nobody wanted rather than bridging a burst. Now anchored to the invalidation, with a test for a key nobody reads, and the mutant restoring first-notice fails it. The cost of that is a Stopwatch.GetTimestamp() in OnInvalidate, which is otherwise a few nanoseconds wide and sees every key the server mentions under BCAST - so it is conditional on the policy having asked for a grace period, and the default path is untouched. Read-your-own-writes is enforced structurally rather than by convention: OnLocalWrite stamps the key node with a monotonic ticket, and the grace is refused if any dependency carries a local-write ticket newer than the generation the entry recorded. Monotonic because our own write also echoes back from the server as an ordinary invalidation, and the fact that WE wrote it has to survive that - tested in that order. The window doubles as the cap. Testing that needed the refresh to FAIL: a successful refresh heals the entry, so the first version of that test passed whether or not a cap existed. Now the refresh gets an error reply, which TryComplete refuses, and only the cap can end the stale serving. Mutation-tested four ways: the read-your-own-writes gate, the cap, a later server invalidation erasing the local-write fact, and the grace anchoring. --- design/interpolated-resp-writer.md | 38 ++++ design/interpolated-resp-writer.queue.md | 9 +- .../Interpolated/CachePolicy.cs | 49 +++++ .../Interpolated/RespClientCache.cs | 147 +++++++++++++- .../Interpolated/RespKeyTable.cs | 89 ++++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 4 + .../RespStaleWhileRevalidateTests.cs | 181 ++++++++++++++++++ 7 files changed, 501 insertions(+), 16 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 4123f33fe..6822205f4 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2220,6 +2220,44 @@ It is also **handler-agnostic**: the cache stores the raw reply and parsing happ refresh does not need to know what anybody intended to turn the bytes into. That is what makes a background refresh a few lines rather than a design. +#### Built: invalidation as a grace period + +`CachePolicy.InvalidationGracePeriod` is the other half, and the more valuable one: age is staggered across +entries, but an **invalidation lands for every reader of a key at the same instant**. That is the thundering +herd exactly, and time-based smoothing cannot touch it, because the trigger was not time. + +**It is a grace period, not a licence.** It moves the entry's hard expiry to "now plus this", measured from +the **invalidation**. The case worth protecting is a key under constant access, where the herd forms +immediately; a key nobody is reading should just expire, and does - nobody arrives inside the window, so +nothing is served and it goes on the next sweep. + +**The clock starts at the invalidation, not at first notice** - and the first version had this wrong. First +notice is cheaper (it needs no timestamp on the invalidation path) but it means a key invalidated an hour +ago is served stale to whoever reads it next, which is the opposite of the intent: the point is to bridge a +burst, not to resurrect something nobody wanted. There is a test for a key nobody reads, and the mutant that +restores the first-notice version fails it. + +The cost of getting it right is a `Stopwatch.GetTimestamp()` in `OnInvalidate`, which is otherwise a few +nanoseconds wide and sees **every** key the server mentions under `BCAST`. So it is **conditional**: the +stamp is taken only when the policy has asked for a grace period, and the default path is unchanged. + +**Read-your-own-writes is enforced structurally**, not by convention. `OnLocalWrite` marks the key node with +a monotonic ticket, and an entry is refused the grace if any key it depends on carries a local-write ticket +newer than the generation it recorded. "No observer can prove the order" excuses serving through *somebody +else's* write; it says nothing about ours, and handing a caller back the value they just replaced is +reported as corruption, not staleness. Monotonic because our own write echoes back from the server as an +ordinary invalidation - the fact that we wrote it has to survive that, and there is a test for the two +arriving in that order. + +The window is also the **cap**: on a hot-written key every refresh is invalidated before it can be stored, +so an unbounded one would serve stale for ever. The test for this has to make the refresh *fail*, otherwise +a successful refresh heals the entry and the assertion passes whether or not there is a cap - which is how +the first version of it silently proved nothing. + +`ServedStale` counts answers that were knowingly out of date - its own counter rather than folded into hits, +because a deployment should be able to see how many it served without reading the configuration to work out +whether it could have. + #### Risks to design for, not discover - **Compounding staleness on a hot-written key.** Every refresh is invalidated in flight, `AllValid` diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index db56f5606..6b9579734 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -18,12 +18,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. -- [ ] **Invalidation-based SWR** (§6.15), the remaining half. Expiry-based SWR is built. This one is - opt-in and deliberately separate: serving through an invalidation means serving data the server has - said is wrong. Must **not** apply to invalidations we caused ourselves (read-your-own-writes); measure - the window from first notice rather than from the invalidation; and it needs the absolute cap, since a - hot-written key would otherwise serve stale indefinitely as each refresh is invalidated in flight. - - [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and @@ -96,7 +90,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". wired through `TransitionalDatabase` — `30d28d70` - [x] `RespResult` shares the reply buffer instead of copying it — `696a5c3f` - [x] Interface-based default handler lookup; `IRespHandler` made invariant — `a539a538` -- [x] Stale-while-revalidate on expiry, with background refresh — this change +- [x] Stale-while-revalidate on expiry, with background refresh — `253cc2e4` +- [x] Invalidation grace period, with the read-your-own-writes carve-out — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/CachePolicy.cs b/src/StackExchange.Redis/Interpolated/CachePolicy.cs index ec99cbc24..e7db94f55 100644 --- a/src/StackExchange.Redis/Interpolated/CachePolicy.cs +++ b/src/StackExchange.Redis/Interpolated/CachePolicy.cs @@ -77,9 +77,58 @@ public sealed class CachePolicy /// public TimeSpan RefreshAfter { get; init; } = TimeSpan.Zero; + /// + /// A grace period after an invalidation, during which the old value may still be served while a + /// refresh runs. + /// + /// + /// + /// The same stampede protection as , triggered by an invalidation instead + /// of by age - and the more valuable of the two, because an invalidation lands for every + /// reader of a popular key at the same instant. That is the thundering herd exactly, and no amount + /// of time-based smoothing helps, because the trigger was not time. + /// + /// + /// Read it as a grace period, not a licence. It effectively moves the entry's hard expiry to + /// "now plus this", measured from the invalidation. The case worth protecting is a key under + /// constant access, where the herd forms instantly; a key that is not being read constantly should + /// simply expire, and does - nobody arrives inside the window, so nothing is served and the entry + /// goes on the next sweep. + /// + /// + /// That is why the clock starts at the invalidation and not at the first read that notices. Starting + /// at first notice would let a key invalidated an hour ago be served stale by whoever happened to + /// read it next, which is the opposite of the intent: the point is to bridge a burst, not to + /// resurrect something nobody wanted. + /// + /// + /// It is also the cap. On a hot-written key every refresh is invalidated before it can be + /// stored, so an unbounded window would serve stale for ever. + /// + /// + /// Serving through an invalidation is a stronger claim than serving something merely old: the + /// server has said this value is wrong and we are answering with it anyway. The justification is + /// that no observer can prove the order - a caller arriving now might equally have arrived a moment + /// before the write - and that holds for somebody else's write. It does not hold for our own, + /// and this never applies to those. + /// + /// + /// Off by default. Turning it on also turns on a timestamp read in the invalidation path, + /// which is otherwise a few nanoseconds wide and sees every key the server mentions - so the cost + /// lands only on those who asked for the feature. + /// + /// + public TimeSpan InvalidationGracePeriod { get; init; } = TimeSpan.Zero; + /// Whether this policy asks for background refresh at all. internal bool RefreshesEarly => RefreshAfter > TimeSpan.Zero && RefreshAfter < TimeToLive; + /// Whether an invalidated entry may be served while it is refreshed. + internal bool ServesStale => InvalidationGracePeriod > TimeSpan.Zero; + + /// as a tick count. + internal long ServeStaleTicks => ToTicks(InvalidationGracePeriod); + /// as a tick count. internal long RefreshAfterTicks => ToTicks(RefreshAfter); diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index cc86ae613..a2aa3b0ac 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -58,6 +58,7 @@ public sealed class RespClientCache : IDisposable private long _coalesced; private long _expired; private long _refreshes; + private long _servedStale; /// Create a cache. /// How entries behave; when null. @@ -84,6 +85,14 @@ public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) /// public long Refreshes => Volatile.Read(ref _refreshes); + /// Reads answered from an entry the server had already invalidated. + /// + /// Its own counter rather than folded into hits: these are answers that were knowingly out of date, + /// and a deployment should be able to see how many it served without reading the configuration to + /// find out whether it could have. + /// + public long ServedStale => Volatile.Read(ref _servedStale); + /// Hits refused because the entry had outlived its lifetime. /// /// Distinct from an invalidation: nobody told us this was wrong, we simply stopped trusting it. A @@ -166,7 +175,29 @@ public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) /// key touched on the server. The work is: hash the span, one array read, one bucket scan. Nothing /// is allocated, and a key we do not track costs only that. /// - public bool OnInvalidate(ReadOnlySpan key) => _keys.Invalidate(key); + public bool OnInvalidate(ReadOnlySpan key) => _keys.Invalidate(key, local: false, Policy.ServesStale); + + /// + /// Note that this process wrote a key, which is a stronger statement than an invalidation + /// arriving from the server. + /// + /// The key we wrote. + /// true if the key was being tracked. + /// + /// + /// Two jobs. It invalidates, like any other notice - and it closes the door on + /// for this entry, permanently. Serving + /// through somebody else's write is defensible because no observer can prove the order; serving + /// through our own is handing a caller back the value they just replaced. + /// + /// + /// Also worth calling on the way out of a write even with server-assisted tracking switched on: the + /// echo takes a round trip to come back, and this closes the window in between. It is safe to + /// over-call - invalidating something twice costs a miss, which is the direction this design always + /// errs in. + /// + /// + public bool OnLocalWrite(ReadOnlySpan key) => _keys.Invalidate(key, local: true, Policy.ServesStale); /// /// Invalidate everything - a null invalidation (FLUSHALL/FLUSHDB), a lost connection, @@ -231,9 +262,17 @@ public bool TryGet( { shouldRefresh = false; - if (_entries.TryGetValue(new EntryKey(frame, database), out var entry) - && entry.IsValid - && entry.Payload.TryRetain()) + if (!_entries.TryGetValue(new EntryKey(frame, database), out var entry)) + { + payload = null; + return false; + } + + // invalidated, but perhaps still servable for a moment - see TryServeStale for why that is a + // decision rather than a shortcut + if (!entry.IsValid) return TryServeStale(entry, out payload, out shouldRefresh); + + if (entry.Payload.TryRetain()) { // re-check after retaining: an invalidation between the check and the retain would otherwise // let one stale read through the door it had already closed @@ -267,6 +306,59 @@ public bool TryGet( return false; } + /// + /// Serve an invalidated entry, briefly, while a refresh runs - if the policy allows it. + /// + /// + /// + /// The stampede that matters most. Time-based refresh smooths out entries that age at different + /// moments; an invalidation arrives for every reader of a popular key at the same instant, and + /// no amount of time-smoothing helps, because the trigger was not time. + /// + /// + /// Three gates, each doing real work: + /// + /// + /// + /// The policy has to have asked for it. Serving a value the server has called wrong is a decision. + /// + /// + /// Never for our own writes. "No observer can prove the order" justifies serving through + /// somebody else's write; it says nothing about ours, and handing a caller back the value they just + /// replaced is reported as corruption rather than as staleness. + /// + /// + /// A window measured from first notice, which doubles as the absolute cap: on a hot-written key + /// every refresh is invalidated before it can be stored, so without a bound this would serve stale + /// for ever. + /// + /// + /// + private bool TryServeStale(Entry entry, out RespPayload? payload, out bool shouldRefresh) + { + shouldRefresh = false; + payload = null; + + if (!Policy.ServesStale || entry.WrittenLocally) return false; + + // measured from the INVALIDATION, not from whenever somebody first looked: a key nobody has read + // for an hour should expire, not be resurrected by the next reader to wander past + var staleSince = entry.StaleSince; + if (staleSince == 0 || CachePolicy.IsOlderThan(staleSince, Policy.ServeStaleTicks)) return false; + + if (!entry.Payload.TryRetain()) return false; + + if (entry.TryClaimRefresh()) + { + Interlocked.Increment(ref _refreshes); + shouldRefresh = true; + } + + Interlocked.Increment(ref _servedStale); + payload = entry.Payload; + return true; + } + /// /// Begin a fill for a background refresh, from a request that has already been rendered. /// @@ -748,6 +840,35 @@ internal readonly struct Dependency(RespKeyTable.Node node, long generation) // a dereference and a compare - no hashing, no lookup in table 2 internal bool IsValid => _node.Generation == _generation; + /// Whether this process wrote this key after the dependency was captured. + internal bool LocalWriteSince => _node.LocalWriteAt > _generation; + + /// The earliest recorded invalidation among these keys, or zero if none was recorded. + internal static long EarliestInvalidation(Dependency[] dependencies) + { + long earliest = 0; + foreach (var dependency in dependencies) + { + if (dependency.IsValid) continue; + + var at = dependency._node.InvalidatedAt; + if (at != 0 && (earliest == 0 || at < earliest)) earliest = at; + } + + return earliest; + } + + /// Whether any of these keys was written by this process since they were captured. + internal static bool AnyLocalWrite(Dependency[] dependencies) + { + foreach (var dependency in dependencies) + { + if (dependency.LocalWriteSince) return true; + } + + return false; + } + internal static bool AllValid(Dependency[] dependencies) { foreach (var dependency in dependencies) @@ -788,6 +909,24 @@ private sealed class Entry(RespPayload payload, Dependency[] dependencies) internal bool IsValid => Dependency.AllValid(dependencies); + /// Whether this process wrote any of the keys this entry depends on, since it was filled. + /// + /// The read-your-own-writes gate. An entry invalidated by our own write must never be served + /// afterwards, however briefly - that is not staleness the caller can shrug at, it is the caller + /// being handed back the value they just replaced. + /// + internal bool WrittenLocally => Dependency.AnyLocalWrite(dependencies); + + /// + /// When this entry became stale: the earliest invalidation among the keys it depends on. + /// + /// + /// Earliest, because that is the moment the entry stopped being right - a later invalidation of + /// a second key does not restart the grace period. Zero when nothing recorded a time, which + /// means the policy was not asking for one. + /// + internal long StaleSince => Dependency.EarliestInvalidation(dependencies); + /// /// Claim the right to refresh this entry, once. /// diff --git a/src/StackExchange.Redis/Interpolated/RespKeyTable.cs b/src/StackExchange.Redis/Interpolated/RespKeyTable.cs index 99cf98609..a676c6680 100644 --- a/src/StackExchange.Redis/Interpolated/RespKeyTable.cs +++ b/src/StackExchange.Redis/Interpolated/RespKeyTable.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading; @@ -21,7 +22,7 @@ namespace StackExchange.Redis.Interpolated /// long compare - no hashing, no second lookup. The cost of that is an invariant: a node that /// leaves this table must be stamped invalid FIRST, or entries still referencing it would never learn /// and would serve stale data forever. Everything that removes here goes through - /// on the way out. + /// Node.Invalidate on the way out. /// /// /// Lookups are lock-free and allocation-free, because in broadcasting mode this is fed every key @@ -61,6 +62,8 @@ internal RespKeyTable(int capacity = 256) internal sealed class Node { private long _generation; + private long _localWriteAt; + private long _invalidatedAt; internal Node(byte[] key, int hash, long generation) { @@ -77,7 +80,65 @@ internal Node(byte[] key, int hash, long generation) internal long Generation => Volatile.Read(ref _generation); /// Mark the key invalidated; every entry that recorded a generation now fails to validate. - internal void Invalidate() => Volatile.Write(ref _generation, Invalid); + internal void Invalidate() => Invalidate(stampTime: false); + + /// Mark the key invalidated, optionally recording when. + /// + /// Whether to record when this happened, for a grace period that runs from the + /// invalidation. + /// + /// + /// Optional because this is the hot path: under BCAST the server names every key anybody + /// modifies, and the overwhelming majority are keys we do not hold. Reading a timestamp there + /// would be paid on all of them to benefit the few. So the cost lands only on a cache that has + /// actually asked for a grace period - see . + /// + /// Stamped before the generation is cleared, so a reader that sees the node invalid can + /// rely on the timestamp already being there. + /// + /// + internal void Invalidate(bool stampTime) + { + if (stampTime) Volatile.Write(ref _invalidatedAt, Stopwatch.GetTimestamp()); + Volatile.Write(ref _generation, Invalid); + } + + /// When this key was last invalidated, if anybody asked for that to be recorded. + internal long InvalidatedAt => Volatile.Read(ref _invalidatedAt); + + /// + /// The ticket current when this process last wrote this key, or . + /// + /// + /// Tickets are globally monotonic, so comparing this against the generation an entry recorded + /// answers "did we write this key after that entry was filled?" without storing a timestamp or + /// walking anything. It exists solely to keep our own writes out of any + /// serve-stale-anyway behaviour: that is read-your-own-writes, and it is reported as corruption + /// rather than as staleness. See design notes 6.15. + /// + internal long LocalWriteAt => Volatile.Read(ref _localWriteAt); + + /// + /// Mark the key invalidated by us, which is a stronger statement than an invalidation + /// arriving from the server. + /// + /// + /// Stamped before the invalidation, so a reader that sees the node invalid can trust that this + /// has already been set if it was going to be. Monotonic, so a later server invalidation cannot + /// erase the fact that we wrote it. + /// + internal void InvalidateLocal(bool stampTime) + { + var ticket = NextTicket(); + while (true) + { + var current = Volatile.Read(ref _localWriteAt); + if (current >= ticket) break; + if (Interlocked.CompareExchange(ref _localWriteAt, ticket, current) == current) break; + } + + Invalidate(stampTime); + } /// /// The generation to record for a fill starting now, reviving the node with a fresh ticket if it @@ -127,11 +188,29 @@ internal long EnsureLive() /// broadcasting, is almost every call. /// /// true if the key was tracked, so callers can count how much of the flood mattered. - internal bool Invalidate(ReadOnlySpan key) + internal bool Invalidate(ReadOnlySpan key) => Invalidate(key, local: false, stampTime: false); + + /// + /// The key that changed. + /// + /// true if this process made the change. A local write is a fact, not a race, so an + /// entry it invalidates must never be served afterwards. + /// + /// Whether to record when this happened, for a grace period. + internal bool Invalidate(ReadOnlySpan key, bool local, bool stampTime) { var node = Find(key); if (node is null) return false; - node.Invalidate(); + + if (local) + { + node.InvalidateLocal(stampTime); + } + else + { + node.Invalidate(stampTime); + } + return true; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index a18912ae1..192c7d25e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -353,3 +353,7 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.RespClientCache.Refreshes.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload, out bool shouldRefresh) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginRefresh(in StackExchange.Redis.Interpolated.RespRequest request, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool +[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.OnLocalWrite(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long diff --git a/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs index ab0257a01..fcddb4d03 100644 --- a/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs +++ b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs @@ -178,6 +178,187 @@ public async Task ReplacingAnEntryReleasesTheSupersededReply() Assert.Equal(1, cache.Count); // replaced, not duplicated } + // ---- invalidation-triggered, the opt-in half ----------------------------------------------------- + + [Fact] + public async Task AnInvalidatedEntryIsServedBrieflyAndRefreshed() + { + // the stampede that matters most: an invalidation lands for EVERY reader of a hot key at the same + // instant, so time-based smoothing cannot help - the trigger was not time + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromSeconds(5), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal(1, executor.Sends); + + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); // somebody else wrote it + + // still answered - knowingly out of date, and counted as such + Assert.Equal("a", await Get(context)); + Assert.Equal(1, cache.ServedStale); + + // ...with a refresh started behind it, so the next reader gets the new value + Assert.True(await WaitFor(() => cache.Stored == 2), "no refresh followed the invalidation"); + Assert.Equal("b", await Get(context)); + } + + [Fact] + public async Task OurOwnWriteIsNeverServedThrough() + { + // read-your-own-writes. "No observer can prove the order" excuses serving through somebody else's + // write; it says nothing about ours, and returning the value the caller just replaced is reported + // as corruption rather than as staleness. + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromSeconds(5), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + + cache.OnLocalWrite(Encoding.UTF8.GetBytes("k")); // WE wrote it + + Assert.Equal("b", await Get(context)); // a real miss, not a stale serve + Assert.Equal(0, cache.ServedStale); + Assert.Equal(2, executor.Sends); + } + + [Fact] + public async Task ALocalWriteStillCountsAfterAServerInvalidation() + { + // the two can arrive in either order - our own write echoes back from the server as well - and the + // fact that WE wrote it must survive that + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromSeconds(5), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + + cache.OnLocalWrite(Encoding.UTF8.GetBytes("k")); + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); // the echo, arriving afterwards + + Assert.Equal("b", await Get(context)); + Assert.Equal(0, cache.ServedStale); + } + + [Fact] + public async Task ServingThroughInvalidationIsOffByDefault() + { + Assert.Equal(TimeSpan.Zero, CachePolicy.Default.InvalidationGracePeriod); + + using var cache = new RespClientCache(); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); + + Assert.Equal("b", await Get(context)); // straight to the server + Assert.Equal(0, cache.ServedStale); + } + + [Fact] + public async Task TheWindowIsAlsoTheCap() + { + // on a hot-written key every refresh is invalidated before it can be stored, so without an absolute + // bound this would serve stale for ever. Measured from FIRST NOTICE, so it cannot. + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromMilliseconds(80), + TimeToLive = TimeSpan.FromMinutes(5), + }); + // The refresh must NOT be allowed to succeed, or it heals the entry and the test cannot tell the cap + // from the cure. An error reply is refused by TryComplete, so the entry stays invalid - which is + // precisely the hot-written-key situation the cap is for: every refresh is lost, and without a bound + // the entry would be served stale for ever. + var executor = new CountingExecutor("$1\r\na\r\n", "-ERR not today\r\n", "$1\r\nc\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); + + Assert.Equal("a", await Get(context)); // inside the window: served stale, notice recorded + Assert.Equal(1, cache.ServedStale); + + Assert.True(await WaitFor(() => executor.Sends == 2), "the refresh never ran"); + Assert.True(await WaitFor(() => cache.RefusedError == 1), "the refresh was not refused"); + Assert.Equal(1, cache.Count); // still the original, still invalid + + await Task.Delay(200); // past the window + + Assert.Equal("c", await Get(context)); // the window closed; a real fetch + Assert.Equal(1, cache.ServedStale); // and NOT another stale serve + } + + [Fact] + public async Task AKeyNobodyIsReadingJustExpires() + { + // The grace period runs from the INVALIDATION, not from whoever next happens to look. The case + // worth protecting is a key under constant access, where the herd forms the instant it is + // invalidated. A key nobody is reading should simply expire - starting the clock at first notice + // would instead resurrect it for whoever wandered past an hour later, which is the opposite of the + // intent. + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromMilliseconds(80), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); + + // nobody reads it during the grace period + await Task.Delay(200); + + Assert.Equal("b", await Get(context)); // a real fetch, not a stale serve + Assert.Equal(0, cache.ServedStale); + Assert.Equal(0, cache.Refreshes); // and no background work was started for it either + } + + [Fact] + public async Task TheGraceIsNotRestartedByLaterReads() + { + // it is a grace period, not a sliding window: constant access bridges the burst, it does not keep + // the old value alive indefinitely + using var cache = new RespClientCache(new CachePolicy + { + InvalidationGracePeriod = TimeSpan.FromMilliseconds(120), + TimeToLive = TimeSpan.FromMinutes(5), + }); + var executor = new CountingExecutor("$1\r\na\r\n", "-ERR not today\r\n", "$1\r\nc\r\n"); + var context = Context(executor, cache); + + Assert.Equal("a", await Get(context)); + cache.OnInvalidate(Encoding.UTF8.GetBytes("k")); + + // read repeatedly across the window; the refresh keeps failing, so only the cap can stop this + var served = 0; + var watch = Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < 300) + { + if ((string?)await Get(context) == "a") served++; + await Task.Delay(15); + } + + Assert.True(served > 0, "nothing was served during the grace period"); + Assert.True( + watch.ElapsedMilliseconds > 250 && (string?)await Get(context) != "a", + "still serving the old value long after the grace period"); + } + /// The same rendered key the surface would produce, for poking the cache directly. private static RespRequest RenderKey(RespContext context) { From f2811156e1c594e721f06609ec24c8112e7f4a72 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 21:05:07 +0100 Subject: [PATCH 126/360] Flush the cache when a connection is lost cache.FlushOnDisconnect(multiplexer) hooks ConnectionFailed and empties the cache; the returned IDisposable unsubscribes. Not a tidy-up. Server-assisted invalidation only works while somebody is listening: anything that changes during a disconnect is never announced, because the server forgets a client it has lost. An entry that survives the gap is wrong with nothing left in the system that will ever say so. There is a test asserting that failure WITHOUT the hook, because watching it happen is more convincing than asserting it would. Flushes on any connection failure rather than reasoning about whether that particular one was carrying invalidations. Over-flushing costs a round trip per key; under-flushing serves wrong data with no bound on how long for, which is the direction this design errs in everywhere else. It does not cover a socket that is quietly dead, because no event is raised for one. That is what CachePolicy.TimeToLive is for, and is the concrete reason it must never be infinite: the hook handles detected failure, the lifetime bounds undetected failure. Explicit for now because the cache hangs off a context rather than being owned by the multiplexer; it becomes part of constructing a cache-aware database when GetDatabase() returns one. A cache nobody remembered to wire up is a cache that goes quietly wrong. Mutation-tested: not flushing, and Dispose not unsubscribing. --- design/interpolated-resp-writer.md | 24 ++++ design/interpolated-resp-writer.queue.md | 7 +- .../RespCacheConnectionExtensions.cs | 81 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../RespCacheDisconnectTests.cs | 135 ++++++++++++++++++ 5 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs create mode 100644 tests/StackExchange.Redis.Tests/RespCacheDisconnectTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 6822205f4..58e625582 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2220,6 +2220,30 @@ It is also **handler-agnostic**: the cache stores the raw reply and parsing happ refresh does not need to know what anybody intended to turn the bytes into. That is what makes a background refresh a few lines rather than a design. +#### Built: flushing on disconnect + +`cache.FlushOnDisconnect(multiplexer)` subscribes to `ConnectionFailed` and empties the cache; the returned +`IDisposable` unsubscribes. + +**Not a tidy-up.** Server-assisted invalidation only works while somebody is listening. Anything that +changes during a disconnect is never announced - the server forgets a client it has lost - so an entry that +survives the gap is wrong, with nothing left in the system that will ever say so. There is a test asserting +exactly that failure without the hook, because "it would be stale" is much less convincing than watching it +happen. + +It flushes on **any** connection failure rather than reasoning about whether that particular connection was +carrying invalidations. Over-flushing costs a round trip per key; under-flushing serves wrong data with no +bound on how long for, and that is the direction this design errs in everywhere else. + +It does **not** cover a connection that has failed and nobody has noticed - no event is raised for a socket +that is quietly dead. That is what `CachePolicy.TimeToLive` is for, and is the concrete reason it must never +be infinite (§6.14). The two are a pair: the hook handles detected failure, the lifetime bounds undetected +failure. + +Explicit for now because the cache hangs off a context rather than being owned by the multiplexer. When +`GetDatabase()` returns a cache-aware database this becomes part of constructing one - which is the right +end state, because a cache nobody remembered to wire up is a cache that goes quietly wrong. + #### Built: invalidation as a grace period `CachePolicy.InvalidationGracePeriod` is the other half, and the more valuable one: age is staggered across diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 6b9579734..cba8fa444 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -23,10 +23,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and small. `DUMP` wants a second opinion. -- [ ] **Wire `OnFlush()` to disconnect.** It exists and nothing calls it. Comes from the same server - documentation that gave us the TTL backstop: *"if the connection is lost, the local cache is - flushed"*. Currently the cache would serve entries invalidated while we were not listening. - ## Next - [ ] **`Parse(ref RespReader)`** (§2.2, §6.16). Smaller prize than it looked once the outgoing-copy rule @@ -91,7 +87,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] `RespResult` shares the reply buffer instead of copying it — `696a5c3f` - [x] Interface-based default handler lookup; `IRespHandler` made invariant — `a539a538` - [x] Stale-while-revalidate on expiry, with background refresh — `253cc2e4` -- [x] Invalidation grace period, with the read-your-own-writes carve-out — this change +- [x] Invalidation grace period, with the read-your-own-writes carve-out — `6b94d588` +- [x] Flush the cache when a connection is lost — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs b/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs new file mode 100644 index 000000000..6c10b7fe8 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Tying a client-side cache to the connection whose invalidations keep it honest. + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public static class RespCacheConnectionExtensions + { + /// + /// Empty the cache whenever a connection is lost. + /// + /// The cache to flush. + /// The connection to watch. + /// A subscription; dispose it to stop watching. + /// + /// + /// Not optional, and not a tidy-up. Server-assisted invalidation only works while we are + /// listening: anything that changes during a disconnect is never announced, because the server + /// forgets a client it has lost. An entry that survives the gap is stale with nothing left in the + /// system that will ever say so. The Redis documentation puts it plainly - "make sure that if the + /// connection is lost, the local cache is flushed". + /// + /// + /// Flushes on any connection failure rather than trying to work out whether that particular + /// connection was carrying invalidations. Over-flushing costs a round trip per key; under-flushing + /// serves data that is wrong with no bound on how long for, and this design errs in the same + /// direction everywhere else. + /// + /// + /// It does not cover the case where a connection has failed and nothing has noticed - no + /// event is raised for a socket that is quietly dead. That is what + /// is for: a finite lifetime bounds the damage when detection + /// itself fails, which is why it must never be infinite. + /// + /// + /// Explicit for now because the cache is attached to a context rather than owned by the multiplexer. + /// When GetDatabase() eventually returns a cache-aware database this becomes part of building + /// one, and callers stop having to remember it - which is the right end state, because a cache + /// nobody remembered to wire up is a cache that goes quietly wrong. + /// + /// + public static IDisposable FlushOnDisconnect(this RespClientCache cache, IConnectionMultiplexer multiplexer) + { + if (cache is null) throw new ArgumentNullException(nameof(cache)); + if (multiplexer is null) throw new ArgumentNullException(nameof(multiplexer)); + + return new DisconnectFlusher(cache, multiplexer); + } + + private sealed class DisconnectFlusher : IDisposable + { + private readonly RespClientCache _cache; + private IConnectionMultiplexer? _multiplexer; + + internal DisconnectFlusher(RespClientCache cache, IConnectionMultiplexer multiplexer) + { + _cache = cache; + _multiplexer = multiplexer; + multiplexer.ConnectionFailed += OnConnectionFailed; + } + + /// + /// Deliberately ignores which endpoint or connection type it was. Deciding that a particular + /// failure could not have cost us an invalidation is a judgement this has no way to make, and + /// getting it wrong is silent. + /// + private void OnConnectionFailed(object? sender, ConnectionFailedEventArgs e) => _cache.OnFlush(); + + public void Dispose() + { + var multiplexer = Interlocked.Exchange(ref _multiplexer, null); + if (multiplexer is not null) multiplexer.ConnectionFailed -= OnConnectionFailed; + } + } + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 192c7d25e..4a6b943a7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -357,3 +357,5 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.init -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnLocalWrite(System.ReadOnlySpan key) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long +[SER010]StackExchange.Redis.Interpolated.RespCacheConnectionExtensions +[SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! diff --git a/tests/StackExchange.Redis.Tests/RespCacheDisconnectTests.cs b/tests/StackExchange.Redis.Tests/RespCacheDisconnectTests.cs new file mode 100644 index 000000000..ea1d1b8ea --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCacheDisconnectTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NSubstitute; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Losing the connection must empty the cache. +/// +/// +/// Server-assisted invalidation only works while somebody is listening: anything that changes during a +/// disconnect is never announced, because the server forgets a client it has lost. An entry that survives +/// the gap is wrong with nothing left in the system that will ever say so. +/// +public class RespCacheDisconnectTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + internal int Sends { get; private set; } + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sends++; + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static ConnectionFailedEventArgs Failure(object sender) => new( + sender, + new DnsEndPoint("localhost", 6379), + ConnectionType.Interactive, + ConnectionFailureType.SocketClosed, + new Exception("boom"), + "physical"); + + private static ValueTask Get(RespContext context) + => context.SendAsync($"{RedisCommand.GET}{(RedisKey)"k"}", CommandFlags.CommandRetryReadOnly); + + [Fact] + public async Task ADisconnectEmptiesTheCache() + { + var multiplexer = Substitute.For(); + using var cache = new RespClientCache(); + using var _ = cache.FlushOnDisconnect(multiplexer); + + var executor = new FakeExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + Assert.Equal("a", await Get(context)); + Assert.Equal("a", await Get(context)); + Assert.Equal(1, executor.Sends); // served from cache + + multiplexer.ConnectionFailed += Raise.EventWith(multiplexer, Failure(multiplexer)); + + // anything could have changed while nobody was listening, so nothing survives + Assert.Equal("b", await Get(context)); + Assert.Equal(2, executor.Sends); + } + + [Fact] + public async Task WithoutTheHookAnEntrySurvivesADisconnect() + { + // the failure this guards against, made explicit: the entry lives on, and no invalidation is ever + // coming for it, because the server forgot us + var multiplexer = Substitute.For(); + using var cache = new RespClientCache(); + + var executor = new FakeExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + Assert.Equal("a", await Get(context)); + multiplexer.ConnectionFailed += Raise.EventWith(multiplexer, Failure(multiplexer)); + + Assert.Equal("a", await Get(context)); // stale, indefinitely + Assert.Equal(1, executor.Sends); + } + + [Fact] + public async Task DisposingTheSubscriptionStopsTheFlushing() + { + var multiplexer = Substitute.For(); + using var cache = new RespClientCache(); + var subscription = cache.FlushOnDisconnect(multiplexer); + + var executor = new FakeExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + Assert.Equal("a", await Get(context)); + subscription.Dispose(); + + multiplexer.ConnectionFailed += Raise.EventWith(multiplexer, Failure(multiplexer)); + + Assert.Equal("a", await Get(context)); // no longer listening + Assert.Equal(1, executor.Sends); + + subscription.Dispose(); // and disposing twice is not an error + } + + [Fact] + public void ItFlushesWhicheverConnectionFailed() + { + // deliberately not trying to decide whether THAT connection was carrying invalidations: getting + // that judgement wrong is silent, and over-flushing only costs a round trip per key + var multiplexer = Substitute.For(); + using var cache = new RespClientCache(); + using var _ = cache.FlushOnDisconnect(multiplexer); + + foreach (var type in new[] { ConnectionType.Interactive, ConnectionType.Subscription }) + { + var args = new ConnectionFailedEventArgs( + multiplexer, + new DnsEndPoint("localhost", 6379), + type, + ConnectionFailureType.SocketClosed, + new Exception("boom"), + "physical"); + + multiplexer.ConnectionFailed += Raise.EventWith(multiplexer, args); + } + + Assert.Equal(0, cache.Count); + } +} From 4d608ddd6404cf769245edccadad3f7ad777528d Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 21:19:04 +0100 Subject: [PATCH 127/360] Host the cache on the multiplexer, and route real invalidations to it The cache had no owner in src: tests built one and attached it per context with WithCache, which is precisely what stopped an invalidation push having anywhere to go. It now hangs off the multiplexer - created in the constructor when ConfigurationOptions.ClientCache names a policy, never replaced, handed to each database's context as it is built. Per-multiplexer is forced rather than chosen: tracking is per connection, and a connection belongs to the multiplexer. PhysicalConnection gains PushKind.Invalidate and handles it before the channel gate, because that gate demands an inline string second element and an invalidation's is an array or a null. It always returns Handled, including with no cache: an invalidation is never the reply to anything we sent, so falling through to command matching would hand it to whoever was at the front of the queue. Anything unreadable over-flushes rather than guesses. OnConnectionFailed flushes first thing - before the disposed check, before handler dispatch, and synchronously - because queueing it leaves a window in which we answer from a cache we already know is suspect. The policy is not part of the connection string: it is durations and correctness choices rather than a name, and null (no cache) is the only safe default. Still by hand: CLIENT TRACKING itself. Negotiation is the next item, and until it lands a caller who sets ClientCache and stops there gets a cache that fills, expires on TTL, and is never invalidated. --- design/interpolated-resp-writer.md | 49 ++++- design/interpolated-resp-writer.queue.md | 16 +- .../ConfigurationOptions.cs | 20 ++ .../ConnectionMultiplexer.Events.cs | 7 + .../ConnectionMultiplexer.cs | 20 ++ .../RespCacheConnectionExtensions.cs | 11 +- .../PhysicalConnection.Read.cs | 64 +++++++ src/StackExchange.Redis/PhysicalConnection.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 2 + src/StackExchange.Redis/RedisDatabase.cs | 3 +- .../StackExchange.Redis.Tests/ConfigTests.cs | 1 + .../RespCacheInvalidationTests.cs | 181 ++++++++++++++++++ 12 files changed, 359 insertions(+), 16 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 58e625582..d90e9472f 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2001,9 +2001,9 @@ for exactly that case, and its policy for anything unknown is worth copying verb Which is also precisely why invalidations are dropped today: they are simply not recognised. -#### What the production integration actually needs +#### What the production integration actually needs — done -Two changes, both small, and now known rather than guessed: +Two changes, both small, and both as predicted: 1. **`PushKind` gains `[AsciiHash("invalidate")] Invalidate`** (`PhysicalConnection.Read.cs`). One line; the generator does the parsing. @@ -2024,6 +2024,51 @@ one push clears several entries, a flush clears everything, and a pub/sub delive disturbs nothing. All four parsing steps are mutation-tested — the discriminator check initially survived its mutant, which is what prompted the pub/sub test. +Both landed as written. `OnInvalidate` sits immediately after the `kind` is decoded and before +`TryMoveNextString`, and always returns `Handled` — including when there is no cache at all, because an +invalidation is never the reply to anything we sent, so letting it fall through to command matching would +hand it to whoever happened to be at the front of the queue. Its three unreadable cases (a payload that is +not an aggregate, a streaming aggregate, a key whose bytes we cannot see contiguously) all **over-flush** +rather than guess: we already know something changed, and the same judgement is made on disconnect. + +#### Where the cache lives + +A cache needs an owner before a push has anywhere to go, and until now there wasn't one: the cache was a +free-standing object that tests built and attached per context with `WithCache`, which is exactly what +blocked this. It now hangs off the **multiplexer** — created in the constructor when +`ConfigurationOptions.ClientCache` names a policy, never replaced (so a reader can take it without a lock), +and handed to each `RedisDatabase`'s context as it is built. + +Per-multiplexer and not per-database is forced, not chosen (§6.14): tracking is per *connection*, and a +connection belongs to the multiplexer. A cache per database would have to be found from here anyway when a +push landed, and a cache per context would be handed the invalidations of a connection it does not own. + +`OnConnectionFailed` flushes it as its very first act — before the disposed check and before the handler +dispatch, and **synchronously** rather than via `CompleteAsWorker`, because queueing it leaves a window in +which we would answer from a cache we already know is suspect. + +The policy is deliberately **not** part of the connection string. A policy is a set of durations and +correctness choices rather than a name, and round-tripping it through text invites it to be configured by +someone who has not read what `InvalidationGracePeriod` actually permits. `null` — no cache — is the only +safe default: a cache changes what a read can return, and nobody should acquire that by upgrading. + +`RespCacheInvalidationTests` proves the whole path through the real client, with `CLIENT TRACKING` still +issued by hand: a write from a second connection evicts what the multiplexer cached, while a key outside +the `PREFIX` filter keeps serving the old value — which is the cache proving it was in the path at all, +since without it that read would have gone to the server and come back changed. A `FLUSHDB` on a dedicated +database empties everything, including that unfiltered key. Three mutations are caught: never matching +`PushKind.Invalidate`, ignoring the null payload, and detaching the cache from the context. + +One thing the test had to learn: under `BCAST` the server announces a matching key to every tracking client +the moment *anyone* writes it, including the test's own setup write on the other connection. If that push +overtakes the reply being filled from, the fill is refused as raced — correctly, since storing it would +cache a value the server has already said is wrong. A test that primes an entry immediately after writing +it therefore has to be prepared to ask twice. + +What is still missing is the negotiation: nothing yet sends `CLIENT TRACKING` on our behalf, so a caller who +sets `ClientCache` and stops there gets a cache that fills, expires on TTL, and is never invalidated. That +is the next item, and it must refuse loudly without RESP3 rather than quietly behave this way. + ### 6.14 Global cache, contextual TTL **The cache is global.** Two facts force it. Tracking is per-*connection* (by client id), and the server diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index cba8fa444..2047346af 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -30,15 +30,13 @@ a line saying why, because "we decided not to" is worth as much as "we did". **composability**: `IRespHandler` built from `IRespHandler`. Cheapest while handlers live in one file. Mechanical: delete two lines per handler, take the parameter. -- [ ] **Route invalidation pushes through `PhysicalConnection`** (§6.13). Two known changes, not guesses: - a `[AsciiHash("invalidate")] Invalidate` member on `PushKind`, and handling it **before** the - `TryMoveNextString` gate — that gate demands an inline string second element (the pub/sub channel), - whereas an invalidation's is an array or a null, so the enum member alone changes nothing. - `TrackingExecutor` in the tests is the known-good target to match. - - [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only, `BCAST`, empty prefix by default (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without - invalidation. + invalidation. **Now the only thing left between `ClientCache` and a cache that works by itself:** + hosting and routing are done, so a caller who sets the policy and never issues `CLIENT TRACKING` + gets a cache that fills, expires on TTL, and is never invalidated — the exact silent-wrongness this + item exists to prevent. Until it lands, `ConfigurationOptions.ClientCache` is experimental in the + strong sense. - [ ] **The rest of the `Execute` family on `TransitionalDatabase`.** `ExecuteResp`/`ExecuteRespAsync` are done (a pass-through; the signatures agree exactly). `Execute`/`ExecuteAsync` returning `RedisResult` @@ -88,7 +86,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Interface-based default handler lookup; `IRespHandler` made invariant — `a539a538` - [x] Stale-while-revalidate on expiry, with background refresh — `253cc2e4` - [x] Invalidation grace period, with the read-your-own-writes carve-out — `6b94d588` -- [x] Flush the cache when a connection is lost — this change +- [x] Flush the cache when a connection is lost — `f2811156` +- [x] Hosting the cache on the multiplexer (`ConfigurationOptions.ClientCache`), and routing real + invalidation pushes to it through `PhysicalConnection` — this change ## Decided against diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 12db0bac1..88d6d29b9 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -19,6 +19,7 @@ using RESPite.Streams; using StackExchange.Redis.Availability; using StackExchange.Redis.Configuration; +using StackExchange.Redis.Interpolated; namespace StackExchange.Redis { @@ -1002,6 +1003,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow SslClientAuthenticationOptions = SslClientAuthenticationOptions, #endif Tunnel = Tunnel, + ClientCache = ClientCache, LibraryName = LibraryName, _protocol = _protocol, heartbeatInterval = heartbeatInterval, @@ -1211,6 +1213,7 @@ private void Clear() SslClientAuthenticationOptions = null; #endif Tunnel = null; + ClientCache = null; _protocol = default; WriteMode = default; CircuitBreaker = null; @@ -1411,6 +1414,23 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) /// public Tunnel? Tunnel { get; set; } + /// + /// EXPERIMENTAL SPIKE. Enables a client-side cache on this connection, and says how its entries behave. + /// + /// + /// + /// - the default - means no cache at all, which is the only safe default: a + /// cache changes what a read can return, and nobody should acquire that by upgrading. + /// + /// + /// Not part of the connection string. A policy is a set of durations and correctness choices rather + /// than a name, and round-tripping it through text would invite it to be configured by someone who + /// had not read what actually permits. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public CachePolicy? ClientCache { get; set; } + /// /// Specify the redis protocol type. /// diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Events.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Events.cs index 0a8b95be5..427522c97 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Events.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Events.cs @@ -13,6 +13,13 @@ public partial class ConnectionMultiplexer public event EventHandler? ConnectionFailed; internal void OnConnectionFailed(EndPoint endpoint, ConnectionType connectionType, ConnectionFailureType failureType, Exception exception, bool reconfigure, string? physicalName) { + // before the disposed check and before the handler dispatch, because this one is not an + // observation: server-assisted invalidation only works while we are listening, so anything that + // changed during the gap is never announced and an entry that survives it is stale with nothing + // left in the system that will ever say so. Synchronous for the same reason - queueing it behind + // CompleteAsWorker leaves a window in which we would answer from a cache we already know is suspect. + ClientCache?.OnFlush(); + if (_isDisposed) return; var handler = ConnectionFailed; if (handler != null) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 939829e9b..db5c3c1e4 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using StackExchange.Redis.Interpolated; using StackExchange.Redis.Profiling; namespace StackExchange.Redis @@ -56,6 +57,16 @@ public sealed partial class ConnectionMultiplexer : IInternalConnectionMultiplex internal EndPointCollection EndPoints { get; } internal ConfigurationOptions RawConfig { get; } + /// + /// EXPERIMENTAL SPIKE. The client-side cache for this connection, if + /// asked for one; otherwise. + /// + /// + /// Created with the multiplexer and never replaced, so a reader can take it without a lock. It is + /// emptied rather than rebuilt when a connection is lost - see . + /// + internal RespClientCache? ClientCache { get; } + /// /// When this multiplexer is a member of a connection group, the group resolves the effective /// circuit-breaker (member override, else this member's own configuration, else the group default) @@ -174,6 +185,15 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se ServerSelectionStrategy = new ServerSelectionStrategy(this); + // one cache per multiplexer, not per database or per context: the invalidations that keep it + // honest arrive on a connection, and a connection belongs to the multiplexer. A cache per + // database would have to be found from here anyway when a push lands, and a cache per context + // would be handed the pushes of a connection it does not own. + if (RawConfig.ClientCache is { Enabled: true } cachePolicy) + { + ClientCache = new RespClientCache(cachePolicy); + } + var configChannel = configuration.ConfigurationChannel; if (!string.IsNullOrWhiteSpace(configChannel)) { diff --git a/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs b/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs index 6c10b7fe8..24bf5a14d 100644 --- a/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs +++ b/src/StackExchange.Redis/Interpolated/RespCacheConnectionExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using RESPite; @@ -38,10 +38,11 @@ public static class RespCacheConnectionExtensions /// itself fails, which is why it must never be infinite. /// /// - /// Explicit for now because the cache is attached to a context rather than owned by the multiplexer. - /// When GetDatabase() eventually returns a cache-aware database this becomes part of building - /// one, and callers stop having to remember it - which is the right end state, because a cache - /// nobody remembered to wire up is a cache that goes quietly wrong. + /// Not needed for a cache the multiplexer owns. A cache asked for by + /// is flushed by the connection itself, which is the + /// right end state: a cache nobody remembered to wire up is a cache that goes quietly wrong. This + /// remains for a cache attached by hand to a context, where nothing else knows it exists. Using both + /// is harmless - a flush of an empty cache costs nothing. /// /// public static IDisposable FlushOnDisconnect(this RespClientCache cache, IConnectionMultiplexer multiplexer) diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index ace43a010..8612dfcb8 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -508,6 +508,13 @@ internal enum PushKind PUnsubscribe, [AsciiHash("sunsubscribe")] SUnsubscribe, + + /// + /// Server-assisted client-side caching: a key we read has changed, or (with a null payload) + /// everything has. Unlike every other kind here, the second element is not a channel. + /// + [AsciiHash("invalidate")] + Invalidate, } internal static partial class PushKindMetadata @@ -574,6 +581,11 @@ static bool TryMoveNextString(ref RespReader reader) => reader.SafeTryMoveNext() & reader.IsInlineScalar & reader.Prefix is RespPrefix.BulkString or RespPrefix.SimpleString; + // before the channel gate below, not inside the switch after it: every other push kind has a + // channel as its second element, and an invalidation has an array of keys (or a null). Reaching + // TryMoveNextString with one of these would reject it as unrecognized. + if (kind is PushKind.Invalidate) return OnInvalidate(muxer, ref reader); + if (kind is PushKind.None || !TryMoveNextString(ref reader)) return OutOfBandResult.NotRecognized; // the channel is always the second element @@ -651,6 +663,58 @@ static bool TryMoveNextString(ref RespReader reader) return OutOfBandResult.NotRecognized; } + /// + /// Hand a CLIENT TRACKING invalidation to the client-side cache, if there is one. + /// + /// + /// + /// The reader is positioned on the invalidate token; the payload follows. A null payload means + /// a flush - FLUSHALL/FLUSHDB, and also the moment tracking is turned off - and is the + /// one invalidation that cannot be filtered by prefix, so it is never safe to ignore. Otherwise it is + /// an array, because one write can name several keys: MSET a b c arrives as a single push. + /// + /// + /// Always , including when we have no cache. An invalidation is + /// never the reply to something we sent, so letting it fall through to command matching would hand it + /// to whoever happened to be at the front of the queue. + /// + /// + private OutOfBandResult OnInvalidate(ConnectionMultiplexer muxer, ref RespReader reader) + { + _readStatus = ReadStatus.Invalidate; + var cache = muxer.ClientCache; + if (cache is null || !reader.SafeTryMoveNext()) return OutOfBandResult.Handled; + + if (reader.IsNull) + { + cache.OnFlush(); + return OutOfBandResult.Handled; + } + + if (!reader.IsAggregate || reader.IsStreaming) + { + // not a shape we understand; over-flush rather than quietly keep entries the server has + // just told us are wrong. Erring this way is the same judgement made on disconnect. + cache.OnFlush(); + return OutOfBandResult.Handled; + } + + var count = reader.AggregateLength(); + for (var i = 0; i < count; i++) + { + if (!reader.SafeTryMoveNext() || !reader.TryGetSpan(out var key)) + { + // a key we cannot see is a key we cannot evict, and we already know it changed + cache.OnFlush(); + return OutOfBandResult.Handled; + } + + cache.OnInvalidate(key); // allocation-free: the key never leaves this span + } + + return OutOfBandResult.Handled; + } + private void OnMessage( ConnectionMultiplexer muxer, in RedisChannel subscriptionChannel, diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 51af1098a..3a5d1cc5d 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1326,6 +1326,7 @@ internal enum ReadStatus ResetArena, ProcessBufferComplete, PubSubUnsubscribe, + Invalidate, // client-side caching NA = -1, } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 4a6b943a7..e33dbc498 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -359,3 +359,5 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long [SER010]StackExchange.Redis.Interpolated.RespCacheConnectionExtensions [SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! +[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CachePolicy? +[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.set -> void diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 478494e9c..467eeb5c6 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -43,7 +43,8 @@ internal RedisDatabase(ConnectionMultiplexer multiplexer, int db, object? asyncS multiplexer.CommandMap, database: Database, serverType: multiplexer.ServerSelectionStrategy.ServerType) - .WithExecutor(new Interpolated.RespMessageExecutor(this, Database)); + .WithExecutor(new Interpolated.RespMessageExecutor(this, Database)) + .WithCache(multiplexer.ClientCache); _haveContext = true; } diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 6c62e3bb2..9fbad1d06 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -70,6 +70,7 @@ orderby name "CertificateValidation", "ChannelPrefix", "CircuitBreaker", + "ClientCache", "ClientName", "commandMap", "configChannel", diff --git a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs new file mode 100644 index 000000000..64ad78b65 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Client-side caching through the real multiplexer: a cache owned by the connection, fed by +/// invalidation pushes that arrive on the same socket as everything else. +/// +/// +/// +/// proves the same loop against a hand-rolled socket, which was the right +/// way to learn the wire format but says nothing about whether routes an +/// invalidation to a cache rather than dropping it as an unrecognized push - or worse, handing it to +/// whichever command happened to be at the front of the queue. +/// +/// +/// Tracking is switched on by hand here. Negotiating it as part of the handshake is a separate step; until +/// then this is the shape a caller would have to use, and it exercises exactly the same routing. +/// +/// +public class RespCacheInvalidationTests(ITestOutputHelper output) : TestBase(output) +{ + private static async Task WaitFor(Func> condition, int millis = 3000) + { + var watch = Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < millis) + { + if (await condition()) return true; + await Task.Delay(25); + } + + return await condition(); + } + + /// + /// Read a key until the reply actually lands in the cache. + /// + /// + /// Not flakiness-papering: this is the cache being right. Under BCAST the server announces + /// a matching key to every tracking client the moment anyone writes it - including the setup + /// write this test just did on another connection. If that push overtakes the reply we are filling + /// from, the fill is refused as raced, because storing it would cache a value the server has already + /// said is wrong. So a test that primes an entry immediately after writing it has to be prepared to ask + /// twice, and one that is not simply fails - which is how this was found. + /// + private static async Task PrimeAsync(IDatabase db, RespClientCache cache, RedisKey key, string expected) + { + var target = cache.Count + 1; + Assert.True( + await WaitFor(async () => + { + Assert.Equal(expected, (string?)await db.Strings.Get(key)); + return cache.Count >= target; + }), + $"'{key}' never cached: stored={cache.Stored} raced={cache.RefusedRaced} " + + $"flags={cache.RefusedByFlags} nokeys={cache.RefusedNoKeys} err={cache.RefusedError}"); + } + + /// + /// A cache-enabled multiplexer with CLIENT TRACKING on, scoped to one prefix. + /// + /// + /// The prefix is not decoration: under BCAST with no prefix this connection is told about every + /// key the rest of the suite touches, and any assertion about a particular key is then competing with + /// that traffic. It also gives the test a control - a key outside the prefix is cached but never + /// announced, so it can show the cache is genuinely serving rather than quietly missing. + /// + private async Task<(ConnectionMultiplexer Muxer, RespClientCache Cache)> TrackedAsync( + string prefix, + CachePolicy? policy = null, + int? database = null) + { + var options = new ConfigurationOptions + { + EndPoints = { { TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort } }, + Protocol = RedisProtocol.Resp3, + ClientCache = policy ?? new CachePolicy(), + DefaultDatabase = database, + AllowAdmin = true, + }; + + ConnectionMultiplexer muxer; + try + { + muxer = await ConnectionMultiplexer.ConnectAsync(options, Writer); + } + catch (Exception ex) + { + Assert.Skip("Unable to connect to server: " + ex.Message); + throw; + } + + var cache = muxer.ClientCache; + Assert.NotNull(cache); + + var db = muxer.GetDatabase(); + var reply = await db.ExecuteAsync("CLIENT", "TRACKING", "ON", "BCAST", "PREFIX", prefix); + Assert.Equal("OK", reply.ToString()); + + return (muxer, cache); + } + + [Fact] + public async Task AWriteElsewhereInvalidatesWhatTheMultiplexerCached() + { + var me = Me(); + var (muxer, cache) = await TrackedAsync(me); + using var _ = muxer; + + // a second connection, with no cache and no tracking: the "somebody else" whose writes we must hear about + using var other = await ConnectionMultiplexer.ConnectAsync(TestConfig.Current.PrimaryServerAndPort, Writer); + var writer = other.GetDatabase(); + + RedisKey tracked = me + ":tracked", untracked = "un" + me + ":untracked"; + await writer.StringSetAsync(tracked, "v1"); + await writer.StringSetAsync(untracked, "v1"); + + var db = muxer.GetDatabase(); + await PrimeAsync(db, cache, tracked, "v1"); + await PrimeAsync(db, cache, untracked, "v1"); + + // a repeat read is served locally: nothing new is stored + var stored = cache.Stored; + Assert.Equal("v1", await db.Strings.Get(tracked)); + Assert.Equal("v1", await db.Strings.Get(untracked)); + Assert.Equal(stored, cache.Stored); + + await writer.StringSetAsync(untracked, "v2"); + await writer.StringSetAsync(tracked, "v2"); + + // the tracked key is announced, so the entry goes and the next read refetches... + Assert.True( + await WaitFor(async () => (string?)await db.Strings.Get(tracked) == "v2"), + "the invalidation for the tracked key never arrived"); + + // ...while the untracked one is outside the PREFIX filter, so nothing is ever said about it and we + // keep serving the value we have. That is the cache proving it was in the path all along - without + // it, this read would have gone to the server and come back "v2" like the other one. + Assert.Equal("v1", await db.Strings.Get(untracked)); + } + + [Fact] + public async Task AFlushPushEmptiesTheWholeCache() + { + var me = Me(); + var dbId = TestConfig.GetDedicatedDB(); + var (muxer, cache) = await TrackedAsync(me, database: dbId); + using var _ = muxer; + + using var other = await ConnectionMultiplexer.ConnectAsync( + new ConfigurationOptions + { + EndPoints = { { TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort } }, + AllowAdmin = true, + }, + Writer); + var writer = other.GetDatabase(dbId); + + // deliberately outside the tracking prefix: a flush is the one invalidation PREFIX cannot filter, + // so if this entry goes, it went because the null payload was understood as "everything you have". + RedisKey key = "un" + me + ":flushed"; + await writer.StringSetAsync(key, "v1"); + + var db = muxer.GetDatabase(); + await PrimeAsync(db, cache, key, "v1"); + + await writer.StringSetAsync(key, "v2"); + Assert.Equal("v1", await db.Strings.Get(key)); // still ours; no push could have named it + + var server = other.GetServer(TestConfig.Current.PrimaryServerAndPort); + await server.FlushDatabaseAsync(dbId); // a dedicated database: FLUSHDB on the shared one would take the suite with it + + Assert.True( + await WaitFor(async () => (string?)await db.Strings.Get(key) is null), + "the flush push never arrived, or did not empty the cache"); + } +} From e42c8d222ad31d8afa3b36029e50b7c2fdc632f6 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 21:41:20 +0100 Subject: [PATCH 128/360] Refuse to cache keys outside the tracked prefixes Under BCAST the server announces only keys matching a PREFIX, so an entry whose key matches none of them has no invalidation path: nothing will ever say it is wrong, and it is served until TimeToLive alone retires it. That is the RefusedNoKeys argument reached from the other side - there the request declared nothing to depend on, here it declared something the server was never asked to watch - and it gets the same answer. CachePolicy.Prefixes carries the list; TryBeginFill refuses anything outside it, counted as RefusedNotTracked. Every key must be tracked, not merely one: the entry depends on all of them, so one untracked key makes the whole reply uninvalidatable. Matching is on the bytes as written to the wire, which is what the server matched and will name back. An empty list means "everything"; an empty string is rejected, because "" would silently turn a narrow list into a total one. Overlap is rejected at construction because CLIENT TRACKING rejects it at the handshake. The list lives on the policy so that the set the cache admits and the set the server agreed to announce are one set - and so the PREFIX arguments come from here once negotiation lands. This deletes a "control" from the integration test that asserted an out-of-prefix key kept serving a stale value. It did prove the cache was in the path; it also enshrined the bug. --- design/interpolated-resp-writer.md | 42 ++++++ design/interpolated-resp-writer.queue.md | 8 +- .../Interpolated/CachePolicy.cs | 105 +++++++++++++++ .../Interpolated/RespClientCache.cs | 38 ++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 3 + .../RespCacheInvalidationTests.cs | 35 ++--- .../RespClientCacheTests.cs | 125 +++++++++++++++++- 7 files changed, 338 insertions(+), 18 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index d90e9472f..5df962ce3 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2069,6 +2069,48 @@ What is still missing is the negotiation: nothing yet sends `CLIENT TRACKING` on sets `ClientCache` and stops there gets a cache that fills, expires on TTL, and is never invalidated. That is the next item, and it must refuse loudly without RESP3 rather than quietly behave this way. +#### A prefix list is also a statement about what may be cached + +The test above originally had a second key, outside the `PREFIX`, and asserted that it **kept serving the +old value** — offered as proof that the cache was in the path at all, since without a cache that read would +have gone to the server and come back changed. It did prove that. It also enshrined a bug. + +Under `BCAST` the server announces only keys matching a prefix. An entry whose key matches none of them has +nothing that will ever say it is wrong: it is served until `TimeToLive` alone retires it. That is the +`RefusedNoKeys` argument reached from the other side — there the request declared nothing to depend on, +here it declared something the server was never asked to watch — and it deserves the same answer. + +So `CachePolicy.Prefixes` now carries the list, and `TryBeginFill` refuses anything outside it, counted as +`RefusedNotTracked`. Four decisions inside that: + +- **Every key, not any.** The entry depends on all of its keys, so one untracked key makes the whole reply + uninvalidatable. `MGET tracked untracked` is not "mostly fine". +- **Bytes, not characters**, compared against the key as written to the wire. That is the only comparison + that means anything: the server matches the bytes it received and names those bytes back, so a context + key-prefix or keyspace isolation is already baked in by the time the cache sees it. +- **Empty list means everything**; an empty *string* is rejected. `""` matches every key, so accepting one + would silently turn a deliberately narrow list into a total one — a cache that looks scoped and is not. +- **Overlap is rejected at construction**, because `CLIENT TRACKING` rejects it at the handshake. Better + the failure lands where the mistake was made. + +The list lives on the policy rather than being derived from anything, for the reason already recorded in +this section: prefixes are connection-global, must not overlap, and cannot be removed individually, whereas +context key-prefixes routinely nest. Declaring it once means the set the cache will admit and the set the +server agreed to announce are **one set** — and when negotiation lands, the `PREFIX` arguments come from +here rather than from a second list that could drift. + +The honest cost: narrowing the prefix list narrows the cache. That is the trade — broadcasting everything +means being told about every key every client touches, and scoping it down buys quiet by only caching +what is in scope. Which is the right way round: the alternative was caching things nobody would ever +correct. + +One thing this does **not** change: a flush is still unfilterable, so `invalidate null` must still be +honoured. It is simply no longer observable through an out-of-prefix entry, because there are none. + +A note on the verification: the empty-prefix rule initially survived its mutant. The test spelled the case +as `["app:", ""]`, which the *overlap* rule catches first — every string starts with `""` — so deleting the +empty check changed nothing. A lone `[""]` is the case that matters, and it now asserts on the message. + ### 6.14 Global cache, contextual TTL **The cache is global.** Two facts force it. Tracking is per-*connection* (by client id), and the server diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 2047346af..d412e4c71 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -32,7 +32,10 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only, `BCAST`, empty prefix by default (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without - invalidation. **Now the only thing left between `ClientCache` and a cache that works by itself:** + invalidation, and the `PREFIX` arguments must come from `CachePolicy.Prefixes` rather than a second + list — the cache already refuses keys outside that set, so the two drifting apart would mean either + caching what nothing announces, or refusing what something does. + **Now the only thing left between `ClientCache` and a cache that works by itself:** hosting and routing are done, so a caller who sets the policy and never issues `CLIENT TRACKING` gets a cache that fills, expires on TTL, and is never invalidated — the exact silent-wrongness this item exists to prevent. Until it lands, `ConfigurationOptions.ClientCache` is experimental in the @@ -88,7 +91,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Invalidation grace period, with the read-your-own-writes carve-out — `6b94d588` - [x] Flush the cache when a connection is lost — `f2811156` - [x] Hosting the cache on the multiplexer (`ConfigurationOptions.ClientCache`), and routing real - invalidation pushes to it through `PhysicalConnection` — this change + invalidation pushes to it through `PhysicalConnection` — `4d608ddd` +- [x] Refuse to cache keys outside `CachePolicy.Prefixes`: no announcement, no invalidation path — this change ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/CachePolicy.cs b/src/StackExchange.Redis/Interpolated/CachePolicy.cs index e7db94f55..4940061b8 100644 --- a/src/StackExchange.Redis/Interpolated/CachePolicy.cs +++ b/src/StackExchange.Redis/Interpolated/CachePolicy.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text; using RESPite; namespace StackExchange.Redis.Interpolated @@ -52,6 +54,109 @@ public sealed class CachePolicy /// Whether this policy permits caching at all. public bool Enabled { get; init; } = true; + /// + /// The key prefixes this connection asks the server to track; empty means all keys. + /// + /// + /// + /// These are the PREFIX arguments of CLIENT TRACKING ... BCAST, and they are declared + /// here rather than derived from anything because the server's rules are not the library's: prefixes + /// are connection-global, must not overlap one another, and cannot be removed individually. + /// Context key-prefixes routinely nest, so they are the wrong source. See design notes 6.13. + /// + /// + /// A prefix list is also a statement about what may be cached. Under BCAST the server + /// announces only keys matching a prefix, so an entry whose key matches none of them has no + /// invalidation path - nothing will ever say it is wrong, and it is served until + /// alone retires it. That is the same defect as caching a keyless reply, + /// and it is refused the same way: see . + /// + /// + /// Narrowing the prefix list therefore narrows the cache. That is the trade being made: broadcasting + /// everything means being told about every key any client touches, and scoping it down buys quiet at + /// the cost of only caching what is in scope. + /// + /// + /// Empty - the default - means BCAST with no prefix: every key is tracked, so every key is + /// cacheable. An empty or null entry is not a way to spell that; it is rejected, because + /// "" matches everything and would silently turn a narrow list into a total one. + /// + /// + public IReadOnlyList Prefixes + { + get => _prefixes; + init + { + _prefixes = value ?? throw new ArgumentNullException(nameof(value)); + _prefixBytes = Encode(_prefixes); + } + } + + private readonly IReadOnlyList _prefixes = Array.Empty(); + private readonly byte[][] _prefixBytes = []; + + /// Whether restricts what may be cached. + internal bool HasPrefixes => _prefixBytes.Length != 0; + + /// + /// Whether a key is inside the tracked set, and so has something that can invalidate it. + /// + /// + /// Compared as bytes, against the key as it was written to the wire. That is the only + /// comparison that means anything: the server matches the bytes it received and names those bytes + /// back, so anything done to the key on the way out - a context key-prefix, keyspace isolation - is + /// already baked in by the time it gets here. + /// + internal bool IsTracked(scoped ReadOnlySpan key) + { + var prefixes = _prefixBytes; + for (var i = 0; i < prefixes.Length; i++) + { + if (key.StartsWith(prefixes[i])) return true; + } + + return false; + } + + /// + /// Overlap is rejected rather than tolerated because the server rejects it: CLIENT TRACKING + /// refuses a prefix list where one entry is a prefix of another. Catching it here means the failure + /// arrives where the mistake was made, rather than as a handshake error much later. + /// + private static byte[][] Encode(IReadOnlyList prefixes) + { + if (prefixes.Count == 0) return []; + + var result = new byte[prefixes.Count][]; + for (var i = 0; i < prefixes.Count; i++) + { + var prefix = prefixes[i]; + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentException( + "An empty cache prefix matches every key; use an empty prefix list to track everything.", + nameof(Prefixes)); + } + + result[i] = Encoding.UTF8.GetBytes(prefix); + } + + for (var i = 0; i < result.Length; i++) + { + for (var j = 0; j < result.Length; j++) + { + if (i != j && result[i].AsSpan().StartsWith(result[j])) + { + throw new ArgumentException( + $"Cache prefixes must not overlap, but '{prefixes[i]}' starts with '{prefixes[j]}'.", + nameof(Prefixes)); + } + } + } + + return result; + } + /// /// How old an entry may get before a read refreshes it in the background, while still being served. /// diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index a2aa3b0ac..77bde2372 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -52,6 +52,7 @@ public sealed class RespClientCache : IDisposable private long _stored; private long _refusedByFlags; private long _refusedNoKeys; + private long _refusedNotTracked; private long _refusedRaced; private long _redundantFills; private long _refusedError; @@ -128,6 +129,22 @@ public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) /// Fills refused because the request named no keys, so nothing could ever invalidate it. public long RefusedNoKeys => Volatile.Read(ref _refusedNoKeys); + /// + /// Fills refused because a key falls outside , so the server will + /// never announce a change to it. + /// + /// + /// The same defect as , arrived at from the other direction: there, the + /// request declared nothing to depend on; here, it declared something the server was never asked to + /// watch. Either way the entry would be served until retires + /// it, with nothing in the system able to say it is wrong sooner. + /// + /// A high count is the signal that the prefix list and the workload disagree - either the list is + /// too narrow to be worth having, or commands are reaching keys nobody meant to cache. + /// + /// + public long RefusedNotTracked => Volatile.Read(ref _refusedNotTracked); + /// Fills refused because an invalidation landed while the command was in flight. public long RefusedRaced => Volatile.Read(ref _refusedRaced); @@ -374,6 +391,11 @@ private bool TryServeStale(Entry entry, out RespPayload? payload, out bool shoul /// Key generations are captured here, before the refresh is sent, exactly as for a first fill: a /// write landing while the refresh is in flight must lose, not win. /// + /// + /// No check, deliberately: a refresh only ever exists for an + /// entry the first fill already admitted, and the policy is fixed for the life of the cache, so + /// re-testing it would be work that cannot change the answer. + /// /// public bool TryBeginRefresh(in RespRequest request, int database, out RespFill fill) { @@ -505,6 +527,22 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, return false; } + // EVERY key, not any: the entry depends on all of them, so one key the server was never asked + // to watch is enough to make the whole reply uninvalidatable. MGET tracked untracked is not + // "mostly fine". + if (Policy.HasPrefixes) + { + for (var i = 0; i < count; i++) + { + if (!Policy.IsTracked(frame.GetKey(ranges[i]))) + { + Interlocked.Increment(ref _refusedNotTracked); + fill = default; + return false; + } + } + } + var deps = count == 0 ? [] : new Dependency[count]; for (var i = 0; i < count; i++) { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e33dbc498..ceaca318a 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -361,3 +361,6 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! [SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CachePolicy? [SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.set -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.Prefixes.get -> System.Collections.Generic.IReadOnlyList! +[SER010]StackExchange.Redis.Interpolated.CachePolicy.Prefixes.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNotTracked.get -> long diff --git a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs index 64ad78b65..304ab88c0 100644 --- a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs +++ b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs @@ -66,8 +66,12 @@ await WaitFor(async () => /// /// The prefix is not decoration: under BCAST with no prefix this connection is told about every /// key the rest of the suite touches, and any assertion about a particular key is then competing with - /// that traffic. It also gives the test a control - a key outside the prefix is cached but never - /// announced, so it can show the cache is genuinely serving rather than quietly missing. + /// that traffic. + /// + /// The same prefix goes on the policy and on the wire, which is the point of + /// : the set the cache will admit and the set the server agreed to + /// announce have to be one set, or entries fall in the gap and stay there. + /// /// private async Task<(ConnectionMultiplexer Muxer, RespClientCache Cache)> TrackedAsync( string prefix, @@ -78,7 +82,7 @@ await WaitFor(async () => { EndPoints = { { TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort } }, Protocol = RedisProtocol.Resp3, - ClientCache = policy ?? new CachePolicy(), + ClientCache = policy ?? new CachePolicy { Prefixes = [prefix] }, DefaultDatabase = database, AllowAdmin = true, }; @@ -121,14 +125,18 @@ public async Task AWriteElsewhereInvalidatesWhatTheMultiplexerCached() var db = muxer.GetDatabase(); await PrimeAsync(db, cache, tracked, "v1"); - await PrimeAsync(db, cache, untracked, "v1"); // a repeat read is served locally: nothing new is stored var stored = cache.Stored; Assert.Equal("v1", await db.Strings.Get(tracked)); - Assert.Equal("v1", await db.Strings.Get(untracked)); Assert.Equal(stored, cache.Stored); + // the untracked key is outside the PREFIX the server agreed to announce, so it is never cached at + // all: a hit there could only ever be retired by the lifetime, with nothing able to say it is wrong + // sooner. It reads correctly every time, straight from the server. + Assert.Equal("v1", await db.Strings.Get(untracked)); + Assert.Equal(1, cache.RefusedNotTracked); + await writer.StringSetAsync(untracked, "v2"); await writer.StringSetAsync(tracked, "v2"); @@ -137,10 +145,9 @@ public async Task AWriteElsewhereInvalidatesWhatTheMultiplexerCached() await WaitFor(async () => (string?)await db.Strings.Get(tracked) == "v2"), "the invalidation for the tracked key never arrived"); - // ...while the untracked one is outside the PREFIX filter, so nothing is ever said about it and we - // keep serving the value we have. That is the cache proving it was in the path all along - without - // it, this read would have gone to the server and come back "v2" like the other one. - Assert.Equal("v1", await db.Strings.Get(untracked)); + // ...and the untracked one was never stale, because it was never stored + Assert.Equal("v2", await db.Strings.Get(untracked)); + Assert.Equal(2, cache.RefusedNotTracked); // both reads of it, refused both times } [Fact] @@ -160,17 +167,15 @@ public async Task AFlushPushEmptiesTheWholeCache() Writer); var writer = other.GetDatabase(dbId); - // deliberately outside the tracking prefix: a flush is the one invalidation PREFIX cannot filter, - // so if this entry goes, it went because the null payload was understood as "everything you have". - RedisKey key = "un" + me + ":flushed"; + RedisKey key = me + ":flushed"; await writer.StringSetAsync(key, "v1"); var db = muxer.GetDatabase(); await PrimeAsync(db, cache, key, "v1"); - await writer.StringSetAsync(key, "v2"); - Assert.Equal("v1", await db.Strings.Get(key)); // still ours; no push could have named it - + // nothing writes the key from here on, so the only thing that can dislodge this entry is the null + // payload being understood as "everything you have is gone". Without that, the value is ours for + // the whole lifetime and the read below keeps saying "v1" long after the server has forgotten it. var server = other.GetServer(TestConfig.Current.PrimaryServerAndPort); await server.FlushDatabaseAsync(dbId); // a dedicated database: FLUSHDB on the shared one would take the suite with it diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index ff3dc6631..1b6d2eddd 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -846,4 +846,127 @@ public async Task ConcurrentInvalidationAndReadsNeverServeStale() Assert.False(TryRead(cache, "abc", out _)); // settled state: gone } } + /// + /// With PREFIX in play, a key outside the tracked set is refused rather than cached. + /// + /// + /// Under BCAST the server announces only keys matching a prefix, so an entry outside the set has + /// nothing that will ever say it is wrong: it would be served until the lifetime alone retired it. That + /// is the same defect as caching a keyless reply, and it gets the same answer. + /// + [Theory] + [InlineData("app:user:1", true)] + [InlineData("app:", true)] // the prefix itself is inside the set + [InlineData("apple", false)] // shares a leading "app" but not the prefix + [InlineData("other:1", false)] + [InlineData("", false)] + public void UntrackedKeysAreNotCached(string key, bool cacheable) + { + using var cache = new RespClientCache(new CachePolicy { Prefixes = ["app:", "session:"] }); + + var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + var admitted = cache.TryBeginFill(ref frame, 0, out var fill); + Assert.Equal(cacheable, admitted); + if (admitted) + { + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + } + else + { + frame.Dispose(); + } + + Assert.Equal(cacheable ? 0 : 1, cache.RefusedNotTracked); + Assert.Equal(cacheable ? 1 : 0, cache.Count); + } + + /// + /// Every key must be tracked, not merely one of them. + /// + /// + /// The entry depends on all of its keys, so one key the server was never asked to watch is enough to + /// make the whole reply uninvalidatable - a write to it would go unannounced and the reply would go on + /// being served. "Mostly invalidatable" is not a thing. + /// + [Fact] + public void OneUntrackedKeySpoilsAMultiKeyCommand() + { + using var cache = new RespClientCache(new CachePolicy { Prefixes = ["app:"] }); + + var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"other"}"); + Assert.False(cache.TryBeginFill(ref frame, 0, out _)); + frame.Dispose(); + Assert.Equal(1, cache.RefusedNotTracked); + + // ...and the same command with every key inside the set is fine + var ok = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"app:c"}"); + Assert.True(cache.TryBeginFill(ref ok, 0, out var fill)); + Assert.True(Complete(cache, fill, "*3\r\n$1\r\n1\r\n$1\r\n2\r\n$1\r\n3\r\n")); + } + + /// An empty prefix list means "track everything", so nothing is refused for being outside it. + [Fact] + public void NoPrefixesMeansEverythingIsCacheable() + { + using var cache = new RespClientCache(new CachePolicy()); // the default: BCAST with no prefix + + var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"anything at all"}"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + Assert.Equal(0, cache.RefusedNotTracked); + } + + /// + /// An empty prefix is rejected rather than treated as "everything". + /// + /// + /// "" matches every key, so accepting it would silently turn a deliberately narrow list into a total + /// one - the failure mode being a cache that looks scoped and is not. An empty list already says + /// "everything", unambiguously. + /// + [Fact] + public void AnEmptyPrefixIsRejected() + { + // ALONE, and checked by message. Paired with a real prefix it is caught by the overlap rule + // instead - every string starts with "" - so that spelling passes even with this rule deleted, + // which is exactly what it did until a mutant walked through it. + var ex = Assert.Throws(() => new CachePolicy { Prefixes = [""] }); + Assert.Contains("matches every key", ex.Message); + + Assert.Throws(() => new CachePolicy { Prefixes = ["app:", ""] }); + } + + /// + /// Overlapping prefixes are rejected here, because the server rejects them there. + /// + /// + /// CLIENT TRACKING refuses a prefix list where one entry is a prefix of another. Catching it at + /// construction puts the failure where the mistake was made rather than in a handshake much later. + /// + [Fact] + public void OverlappingPrefixesAreRejected() + { + var ex = Assert.Throws(() => new CachePolicy { Prefixes = ["app:", "app:user:"] }); + Assert.Contains("must not overlap", ex.Message); + + // ...including a prefix repeated, which overlaps itself in the most literal way available + Assert.Throws(() => new CachePolicy { Prefixes = ["app:", "app:"] }); + } + + /// Prefix matching is on the bytes, so a multi-byte prefix is not matched by accident. + [Fact] + public void PrefixesMatchWholeBytesNotCharacters() + { + using var cache = new RespClientCache(new CachePolicy { Prefixes = ["é:"] }); // 0xC3 0xA9 + + // a key starting with the first byte of the prefix but not the second must not match + var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"è:x"}"); // 0xC3 0xA8 + Assert.False(cache.TryBeginFill(ref frame, 0, out _)); + frame.Dispose(); + + var ok = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"é:x"}"); + Assert.True(cache.TryBeginFill(ref ok, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + } + } From 76744afa5a02b68a75970cf2d04dd386e0ef47fc Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 21:59:25 +0100 Subject: [PATCH 129/360] Record what the prefix rule narrows, and what it leaves --- design/interpolated-resp-writer.queue.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index d412e4c71..87dcb0c5a 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -22,6 +22,13 @@ a line saying why, because "we decided not to" is worth as much as "we did". `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and small. `DUMP` wants a second opinion. + **Narrowed, not closed, by `CachePolicy.Prefixes`.** These are *command*-shaped defects and prefixes + are a *key-space* opt-in, so a non-deterministic command on a declared key is still cached wrongly. + What changed is the blast radius: nothing is cached unless its key space was positively declared, so + a caller who scopes tightly is no longer exposed on key families they never meant to cache at all. + It does largely answer the module worry below for free — `FT.*` names indexes, and an index name is + not usually in a data-key prefix list, so those replies now fall out as `RefusedNotTracked` rather + than being cached with nothing to invalidate them. ## Next @@ -69,7 +76,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [ ] **Module-read tracking.** Do module reads register for invalidation? A five-minute experiment against a real server, never run. Relevant because the docs put the whole `FT.*` family outside - server-side tracking. + server-side tracking — though a prefix list that names data keys already excludes index names, so + the exposure now requires someone to have declared a prefix covering them. - [ ] **`RespContext` sizing.** Currently 48 bytes. `CachePolicy` rides on the cache and the freshness override rides in the service slot, so nothing has grown it yet — but SWR adds knobs, and the From 4358d842abdb2ac5d12d6f8b9e3be1d194128d16 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 22:18:18 +0100 Subject: [PATCH 130/360] An under-declared script is already broken before caching sees it --- src/StackExchange.Redis/Enums/CommandFlags.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index ad2368e90..8c5e475aa 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -119,10 +119,13 @@ public enum CommandFlags /// and never invalidated, and the library cannot know that on your behalf. /// /// - /// A third: a read-only script (EVAL_RO/EVALSHA_RO) that reads a key it did not - /// declare in KEYS[]. Invalidation tracks the declared keys, so an undeclared read is never - /// invalidated and the result stays stale. Declaring every key touched is already required in - /// cluster; this is one more reason for it. + /// A third, with a caveat: a read-only script (EVAL_RO/EVALSHA_RO) that reads a key it + /// did not declare in KEYS[]. Invalidation tracks the declared keys, so an undeclared read is + /// never invalidated and the result stays stale. The caveat is that such a script is already + /// broken: the declared keys are what the client routes on, so in cluster it may not even have + /// reached the node holding the key it computed, hash tags or no. Caching inherits that error rather + /// than introducing it, and cannot fix it - declaring every key touched is the fix, and was the fix + /// before any of this existed. /// /// NoClientCache = 1 << 19, From abd87708793a3569680ee89b856f78de9a523c52 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 22:30:29 +0100 Subject: [PATCH 131/360] Fire-and-forget is never cached, and never served from cache The store side is merely impossible: no reply is observed, so there is nothing to keep and no way to run the error check. The probe side is the one that matters. Fire-and-forget promises the caller default; a cache hit would hand back a real value, so the same call would answer differently depending on whether something else had happened to read that key first. A cache may make a call faster - it may not make it return something else. One mask test alongside NoClientCache, so both suppressors cost a single AND. The defensive "no reply is coming" branches in RespExecutor stay, but are re-commented: fire-and-forget no longer reaches them. Separately, and on the same flag: RespMessageExecutor.Send turned the pipeline's default - null, for fire-and-forget - into throw new RedisException("No reply."), so every synchronous fire-and-forget command on this surface threw. The async twin always passed it straight back; the two now agree. FakeExecutor honours the flag too. One that answers a reply the caller declined makes every assertion about it meaningless, which is how the first version of these tests managed to fail for the wrong reason. --- .../Interpolated/RespClientCache.cs | 19 +++++- .../Interpolated/RespExecutor.cs | 10 ++- .../Interpolated/RespMessageExecutor.cs | 17 ++++- .../RespClientCacheTests.cs | 65 ++++++++++++++++++- .../RespEndToEndTests.cs | 41 +++++++++++- 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 77bde2372..ff0f597c1 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -854,16 +854,31 @@ internal bool PermitsCaching(CommandFlags flags) } /// - /// Whether the command's retry category permits caching at all. + /// Whether the command's flags permit caching at all. /// /// + /// /// The category is a 5-bit severity ladder where zero means "nobody declared one". Both halves of /// this test matter: != 0 rejects the undeclared case, and <= uses the ladder the /// flags were built to support, so anything at or beyond a write - or server-admin - is out. + /// + /// + /// is excluded even when the command is read-only, + /// and the reason is the probe rather than the store. Fire-and-forget promises the caller + /// default; a cache hit would hand back a real value instead, so the same call would answer + /// differently depending on whether something else had happened to read that key first. A cache may + /// make a call faster. It may not make it return something else. + /// + /// + /// The store side is merely impossible rather than wrong: no reply is observed, so there is nothing + /// to keep and no way to run the error check that keeps a failure from being cached. A + /// fire-and-forget read as cache warming is the one coherent reading of the combination, and + /// it cannot work for exactly that reason. + /// /// internal static bool IsCacheable(CommandFlags flags) { - if ((flags & CommandFlags.NoClientCache) != 0) return false; + if ((flags & (CommandFlags.NoClientCache | CommandFlags.FireAndForget)) != 0) return false; var category = flags & Message.MaskRetryCategory; return category != 0 && category <= CommandFlags.CommandRetryReadOnly; diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index c6fcd6db6..c96c6fefe 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -197,7 +197,11 @@ public static TResult Send( { if (filled is null) { - fill.Abandon(); // fire-and-forget: no reply is coming, so nothing can fill this + // no reply is coming, so nothing can fill this. Fire-and-forget used to arrive + // here; it is now refused by the flags before a fill is ever begun, because a + // cache HIT on one would have returned a value where the contract says default. + // Kept for an executor that answers null for some other reason of its own. + fill.Abandon(); // release any waiters, and the key return default!; } @@ -502,7 +506,9 @@ private static async ValueTask AwaitFill( { if (response is null) { - fill.Abandon(); // fire-and-forget: no reply is coming, so nothing can fill this + // as in the synchronous path: fire-and-forget no longer gets this far, but an executor + // may still answer null, and a fill left open would strand its waiters + fill.Abandon(); return default!; } diff --git a/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs index ce0c9f5ff..4d4e830a8 100644 --- a/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespMessageExecutor.cs @@ -41,11 +41,24 @@ internal RespMessageExecutor(RedisBase target, int database) public int Database { get; } + /// Issue the request and return the reply; null if the caller declined one. + /// The rendered request. + /// + /// No reply is an error, except when it was asked for. Fire-and-forget returns the default + /// from the pipeline - which is null here - and that is the answer, not a fault; the asynchronous + /// twin below has always passed it straight back. Without the distinction this threw + /// "No reply." at every synchronous fire-and-forget command on this surface. + /// public RespPayload Send(in RespRequest request) { var message = new FrameMessage(Database, request); - return _target.ExecuteSync(message, PayloadProcessor.Instance) - ?? throw new RedisException("No reply."); + var reply = _target.ExecuteSync(message, PayloadProcessor.Instance); + if (reply is null && (request.Flags & CommandFlags.FireAndForget) == 0) + { + throw new RedisException("No reply."); + } + + return reply!; // null only for fire-and-forget, which every consumer already tests for } public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 1b6d2eddd..cd61edeb5 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -312,7 +312,12 @@ public RespPayload Send(in RespRequest request) Sent++; if (ParkRequests && request.TryRetain(out var retained)) Parked.Add(retained); onSend?.Invoke(); - return RespPayload.Create(Utf8(response)); + + // a real executor captures no reply for fire-and-forget: the caller declined it. Faking one + // would make every assertion about that flag meaningless. + return (request.Flags & CommandFlags.FireAndForget) != 0 + ? null! + : RespPayload.Create(Utf8(response)); } public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) @@ -969,4 +974,62 @@ public void PrefixesMatchWholeBytesNotCharacters() Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); } + /// + /// A fire-and-forget read is not cached, and - the part that matters - is not served from cache. + /// + /// + /// + /// Fire-and-forget promises the caller default. A cache hit would hand back a real value, so the + /// same call would answer differently depending on whether something else had happened to read that key + /// first. A cache may make a call faster; it may not make it return something else. + /// + /// + /// The store side is merely impossible rather than wrong - no reply is observed, so there is nothing to + /// keep and no way to check it was not an error. Which disposes of the one coherent reading of + /// "fire-and-forget read": warming the cache. + /// + /// + [Fact] + public void FireAndForgetIsNeitherCachedNorServed() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + const CommandFlags FireAndForget = CommandFlags.CommandRetryReadOnly | CommandFlags.FireAndForget; + + // nothing stored, and the executor was still asked: the command really was sent + var frame = Get("abc"); + Assert.Null(Via(executor, cache).Send(ref frame, FireAndForget, TextHandler.Instance)); + Assert.Equal(1, executor.Sent); + Assert.Equal(0, cache.Count); + Assert.Equal(1, cache.RefusedByFlags); + + // now cache it properly, so there IS something a probe could wrongly return + var warm = Get("abc"); + Assert.Equal("$5|hello|", Via(executor, cache).Send(ref warm, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + Assert.Equal(1, cache.Count); + + // ...and the fire-and-forget caller still gets default, not the cached value + var again = Get("abc"); + Assert.Null(Via(executor, cache).Send(ref again, FireAndForget, TextHandler.Instance)); + Assert.Equal(3, executor.Sent); + } + + /// The asynchronous path agrees with the synchronous one, including on the hit that isn't. + [Fact] + public async Task FireAndForgetIsNotServedAsynchronouslyEither() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$5\r\nhello\r\n"); + const CommandFlags FireAndForget = CommandFlags.CommandRetryReadOnly | CommandFlags.FireAndForget; + + var warm = Get("abc"); + Assert.Equal("$5|hello|", await Via(executor, cache).SendAsync(ref warm, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + Assert.Equal(1, cache.Count); + + var ff = Get("abc"); + Assert.Null(await Via(executor, cache).SendAsync(ref ff, FireAndForget, TextHandler.Instance)); + Assert.Equal(2, executor.Sent); + Assert.Equal(1, cache.Count); // and it did not disturb what was already there + } + } diff --git a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs index 9e440949f..d4ed682f2 100644 --- a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using System.Threading.Tasks; using StackExchange.Redis.Interpolated; @@ -159,4 +159,43 @@ public async Task TheCacheServesTheSecondReadWithoutTouchingTheServer() Assert.True(cache.OnInvalidate(Encoding.UTF8.GetBytes(key))); Assert.Equal("second", await surface.Strings.Get(key)); } + /// + /// A synchronous fire-and-forget command against a real server returns the default, rather than + /// throwing because no reply arrived. + /// + /// + /// The pipeline returns its default for fire-and-forget, which is for a payload, + /// and this surface used to read that as "no reply" and throw. The asynchronous twin never did, so the + /// two disagreed about the same flag. Sending a real command matters here: nothing but the real + /// executor has the behaviour under test. + /// + [Fact] + public async Task SynchronousFireAndForgetReturnsDefaultRatherThanThrowing() + { + await using var conn = Create(); + var key = Me(); + var db = conn.GetDatabase(); + await db.KeyDeleteAsync(key); + + const CommandFlags Flags = CommandFlags.CommandRetryWriteLastWins | CommandFlags.FireAndForget; + var context = ((IRespTarget)db).Context; + var frame = context.Execute($"{RedisCommand.SET}{(RedisKey)key}{(RedisValue)"marc"}"); + Assert.False(context.Send(ref frame, Flags, RespHandlers.Boolean)); + + // and it really was sent, rather than quietly swallowed + Assert.True(await WaitFor(async () => (string?)await db.StringGetAsync(key) == "marc")); + } + + private static async Task WaitFor(Func> condition, int millis = 2000) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < millis) + { + if (await condition()) return true; + await Task.Delay(25); + } + + return await condition(); + } + } From 286a461af757e0fd977f3a7e9a6aa609c2161dda Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 22:37:44 +0100 Subject: [PATCH 132/360] Split CacheOptions from CachePolicy Two kinds of setting were sharing one type. The test for which is which: does it change what we send or what we record, or only how we interpret what we already hold? The first is a fact about the connection and is settled once - prefixes are the clearest case, since they ARE the argument list sent in CLIENT TRACKING and the server was only told once. The second is applied when an entry is read and never stamped when it is stored, which is what will make it safe to vary per call: the entry is shared, so one copy has to serve callers with different tolerances. So CacheOptions takes Prefixes and Enabled, gains DefaultPolicy, and becomes what ConfigurationOptions.ClientCache holds. CachePolicy keeps only entry behaviour - lifetime, refresh threshold, grace period. No behaviour change; the per-context override is the next step, and the upcoming memory budget now has a home that will not have to move. --- design/interpolated-resp-writer.queue.md | 12 +- .../ConfigurationOptions.cs | 10 +- .../ConnectionMultiplexer.cs | 4 +- .../Interpolated/CacheOptions.cs | 148 ++++++++++++++++++ .../Interpolated/CachePolicy.cs | 122 ++------------- .../Interpolated/RespClientCache.cs | 21 +-- .../PublicAPI/PublicAPI.Unshipped.txt | 18 ++- .../RespCacheInvalidationTests.cs | 6 +- .../RespCacheLifetimeTests.cs | 10 +- .../RespClientCacheTests.cs | 16 +- .../RespStaleWhileRevalidateTests.cs | 46 +++--- 11 files changed, 238 insertions(+), 175 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/CacheOptions.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 87dcb0c5a..89ed14b2c 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -65,6 +65,14 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Later / decide first +- [ ] **Per-context `CachePolicy` override** (`WithCachePolicy`). The other half of the options/policy + split: policy settings are read-time, so they can vary per call, and the override rides in the + context's service slot exactly as `MaxCacheAgeService` does. `WithMaxCacheAge` stays as the + ergonomic spelling of the common case rather than being subsumed. + One wrinkle: `InvalidationGracePeriod` is not purely read-time. Whether grace is on gates a + timestamp *write* in the invalidation path, which sees every key the server mentions. So arming it + belongs on `CacheOptions` and only the duration can vary per context. + - [ ] **`IServer` / `ISubscriber` contexts** still throw from `IRespTarget.Context`. - [ ] **The retry executor** (`WithRetry`). Prerequisites in place; no design written. @@ -100,7 +108,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Flush the cache when a connection is lost — `f2811156` - [x] Hosting the cache on the multiplexer (`ConfigurationOptions.ClientCache`), and routing real invalidation pushes to it through `PhysicalConnection` — `4d608ddd` -- [x] Refuse to cache keys outside `CachePolicy.Prefixes`: no announcement, no invalidation path — this change +- [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` +- [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` +- [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — this change ## Decided against diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 88d6d29b9..b6b1b3039 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -1415,7 +1415,7 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) public Tunnel? Tunnel { get; set; } /// - /// EXPERIMENTAL SPIKE. Enables a client-side cache on this connection, and says how its entries behave. + /// EXPERIMENTAL SPIKE. Enables a client-side cache on this connection, and says how it is built. /// /// /// @@ -1423,13 +1423,13 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) /// cache changes what a read can return, and nobody should acquire that by upgrading. /// /// - /// Not part of the connection string. A policy is a set of durations and correctness choices rather - /// than a name, and round-tripping it through text would invite it to be configured by someone who - /// had not read what actually permits. + /// Not part of the connection string. These are durations, prefixes and correctness choices rather + /// than a name, and round-tripping them through text would invite configuration by someone who had + /// not read what actually permits. /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] - public CachePolicy? ClientCache { get; set; } + public CacheOptions? ClientCache { get; set; } /// /// Specify the redis protocol type. diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index db5c3c1e4..4cc495295 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -189,9 +189,9 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se // honest arrive on a connection, and a connection belongs to the multiplexer. A cache per // database would have to be found from here anyway when a push lands, and a cache per context // would be handed the pushes of a connection it does not own. - if (RawConfig.ClientCache is { Enabled: true } cachePolicy) + if (RawConfig.ClientCache is { Enabled: true } cacheOptions) { - ClientCache = new RespClientCache(cachePolicy); + ClientCache = new RespClientCache(cacheOptions); } var configChannel = configuration.ConfigurationChannel; diff --git a/src/StackExchange.Redis/Interpolated/CacheOptions.cs b/src/StackExchange.Redis/Interpolated/CacheOptions.cs new file mode 100644 index 000000000..733994cdb --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/CacheOptions.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. Everything about a client-side cache that is settled once, when the connection + /// is made. + /// + /// + /// + /// Options versus policy. The test for which side a setting belongs on is whether it changes what + /// we send or what we record, or only how we interpret what we already hold. The + /// first kind is a fact about the connection and lives here. The second kind is + /// , and can vary per call, because it is applied when an entry is read and + /// never stamped when it is stored - which it has to be, since entries are shared and one copy must + /// serve callers with different tolerances. + /// + /// + /// is the clearest case: it is the argument list sent in + /// CLIENT TRACKING ... BCAST, so it cannot vary per call - the server was only told once. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public sealed class CacheOptions + { + /// The default options, used when none are given. + public static CacheOptions Default { get; } = new(); + + /// Whether a cache is created at all. + /// + /// The global switch. Suppressing the cache for one command is + /// , which suppresses the probe as well as the store; this + /// is the deployment-level decision that there is no cache to probe. + /// + public bool Enabled { get; init; } = true; + + /// How entries behave, unless a caller says otherwise. + public CachePolicy DefaultPolicy { get; init; } = CachePolicy.Default; + + /// + /// The key prefixes this connection asks the server to track; empty means all keys. + /// + /// + /// + /// These are the PREFIX arguments of CLIENT TRACKING ... BCAST, and they are declared + /// here rather than derived from anything because the server's rules are not the library's: prefixes + /// are connection-global, must not overlap one another, and cannot be removed individually. + /// Context key-prefixes routinely nest, so they are the wrong source. See design notes 6.13. + /// + /// + /// A prefix list is also a statement about what may be cached. Under BCAST the server + /// announces only keys matching a prefix, so an entry whose key matches none of them has no + /// invalidation path - nothing will ever say it is wrong, and it is served until + /// alone retires it. That is the same defect as caching a + /// keyless reply, and it is refused the same way: see + /// . + /// + /// + /// Narrowing the prefix list therefore narrows the cache. That is the trade being made: broadcasting + /// everything means being told about every key any client touches, and scoping it down buys quiet at + /// the cost of only caching what is in scope. + /// + /// + /// Empty - the default - means BCAST with no prefix: every key is tracked, so every key is + /// cacheable. An empty or null entry is not a way to spell that; it is rejected, because + /// "" matches everything and would silently turn a narrow list into a total one. + /// + /// + public IReadOnlyList Prefixes + { + get => _prefixes; + init + { + _prefixes = value ?? throw new ArgumentNullException(nameof(value)); + _prefixBytes = Encode(_prefixes); + } + } + + private readonly IReadOnlyList _prefixes = Array.Empty(); + private readonly byte[][] _prefixBytes = []; + + /// Whether restricts what may be cached. + internal bool HasPrefixes => _prefixBytes.Length != 0; + + /// + /// Whether a key is inside the tracked set, and so has something that can invalidate it. + /// + /// + /// Compared as bytes, against the key as it was written to the wire. That is the only + /// comparison that means anything: the server matches the bytes it received and names those bytes + /// back, so anything done to the key on the way out - a context key-prefix, keyspace isolation - is + /// already baked in by the time it gets here. + /// + internal bool IsTracked(scoped ReadOnlySpan key) + { + var prefixes = _prefixBytes; + for (var i = 0; i < prefixes.Length; i++) + { + if (key.StartsWith(prefixes[i])) return true; + } + + return false; + } + + /// + /// Overlap is rejected rather than tolerated because the server rejects it: CLIENT TRACKING + /// refuses a prefix list where one entry is a prefix of another. Catching it here means the failure + /// arrives where the mistake was made, rather than as a handshake error much later. + /// + private static byte[][] Encode(IReadOnlyList prefixes) + { + if (prefixes.Count == 0) return []; + + var result = new byte[prefixes.Count][]; + for (var i = 0; i < prefixes.Count; i++) + { + var prefix = prefixes[i]; + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentException( + "An empty cache prefix matches every key; use an empty prefix list to track everything.", + nameof(Prefixes)); + } + + result[i] = Encoding.UTF8.GetBytes(prefix); + } + + for (var i = 0; i < result.Length; i++) + { + for (var j = 0; j < result.Length; j++) + { + if (i != j && result[i].AsSpan().StartsWith(result[j])) + { + throw new ArgumentException( + $"Cache prefixes must not overlap, but '{prefixes[i]}' starts with '{prefixes[j]}'.", + nameof(Prefixes)); + } + } + } + + return result; + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/CachePolicy.cs b/src/StackExchange.Redis/Interpolated/CachePolicy.cs index 4940061b8..56ad63513 100644 --- a/src/StackExchange.Redis/Interpolated/CachePolicy.cs +++ b/src/StackExchange.Redis/Interpolated/CachePolicy.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text; using RESPite; namespace StackExchange.Redis.Interpolated @@ -13,11 +11,15 @@ namespace StackExchange.Redis.Interpolated /// /// /// - /// Deployment-level configuration, held once by the cache rather than passed per call. The one thing - /// that genuinely varies per caller is how stale an answer they will accept, and that rides on the - /// context instead - see . Splitting them that way matches - /// where each decision actually lives: the tracking mode is a fact about the connection, the default - /// lifetime is a fact about the deployment, and freshness tolerance is a fact about the call. + /// How entries behave, not how the cache is built. Everything here is applied when an entry is + /// read and never stamped when it is stored, which is what makes it safe to vary per call: the + /// entry is shared, so one copy has to serve callers with different tolerances. Settings that change + /// what we send or what we record - the tracked prefixes, the memory budget - are facts + /// about the connection and live on instead. + /// + /// + /// The freshness tolerance a particular caller will accept rides on the context - see + /// - and is read-time for exactly the same reason. /// /// /// It also keeps at its 48 bytes: a context carries a reference to shared @@ -51,112 +53,6 @@ public sealed class CachePolicy /// public TimeSpan TimeToLive { get; init; } = TimeSpan.FromMinutes(1); - /// Whether this policy permits caching at all. - public bool Enabled { get; init; } = true; - - /// - /// The key prefixes this connection asks the server to track; empty means all keys. - /// - /// - /// - /// These are the PREFIX arguments of CLIENT TRACKING ... BCAST, and they are declared - /// here rather than derived from anything because the server's rules are not the library's: prefixes - /// are connection-global, must not overlap one another, and cannot be removed individually. - /// Context key-prefixes routinely nest, so they are the wrong source. See design notes 6.13. - /// - /// - /// A prefix list is also a statement about what may be cached. Under BCAST the server - /// announces only keys matching a prefix, so an entry whose key matches none of them has no - /// invalidation path - nothing will ever say it is wrong, and it is served until - /// alone retires it. That is the same defect as caching a keyless reply, - /// and it is refused the same way: see . - /// - /// - /// Narrowing the prefix list therefore narrows the cache. That is the trade being made: broadcasting - /// everything means being told about every key any client touches, and scoping it down buys quiet at - /// the cost of only caching what is in scope. - /// - /// - /// Empty - the default - means BCAST with no prefix: every key is tracked, so every key is - /// cacheable. An empty or null entry is not a way to spell that; it is rejected, because - /// "" matches everything and would silently turn a narrow list into a total one. - /// - /// - public IReadOnlyList Prefixes - { - get => _prefixes; - init - { - _prefixes = value ?? throw new ArgumentNullException(nameof(value)); - _prefixBytes = Encode(_prefixes); - } - } - - private readonly IReadOnlyList _prefixes = Array.Empty(); - private readonly byte[][] _prefixBytes = []; - - /// Whether restricts what may be cached. - internal bool HasPrefixes => _prefixBytes.Length != 0; - - /// - /// Whether a key is inside the tracked set, and so has something that can invalidate it. - /// - /// - /// Compared as bytes, against the key as it was written to the wire. That is the only - /// comparison that means anything: the server matches the bytes it received and names those bytes - /// back, so anything done to the key on the way out - a context key-prefix, keyspace isolation - is - /// already baked in by the time it gets here. - /// - internal bool IsTracked(scoped ReadOnlySpan key) - { - var prefixes = _prefixBytes; - for (var i = 0; i < prefixes.Length; i++) - { - if (key.StartsWith(prefixes[i])) return true; - } - - return false; - } - - /// - /// Overlap is rejected rather than tolerated because the server rejects it: CLIENT TRACKING - /// refuses a prefix list where one entry is a prefix of another. Catching it here means the failure - /// arrives where the mistake was made, rather than as a handshake error much later. - /// - private static byte[][] Encode(IReadOnlyList prefixes) - { - if (prefixes.Count == 0) return []; - - var result = new byte[prefixes.Count][]; - for (var i = 0; i < prefixes.Count; i++) - { - var prefix = prefixes[i]; - if (string.IsNullOrEmpty(prefix)) - { - throw new ArgumentException( - "An empty cache prefix matches every key; use an empty prefix list to track everything.", - nameof(Prefixes)); - } - - result[i] = Encoding.UTF8.GetBytes(prefix); - } - - for (var i = 0; i < result.Length; i++) - { - for (var j = 0; j < result.Length; j++) - { - if (i != j && result[i].AsSpan().StartsWith(result[j])) - { - throw new ArgumentException( - $"Cache prefixes must not overlap, but '{prefixes[i]}' starts with '{prefixes[j]}'.", - nameof(Prefixes)); - } - } - } - - return result; - } - /// /// How old an entry may get before a read refreshes it in the background, while still being served. /// diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index ff0f597c1..d6a809b65 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -62,21 +62,24 @@ public sealed class RespClientCache : IDisposable private long _servedStale; /// Create a cache. - /// How entries behave; when null. + /// How the cache is built; when null. /// Initial size hint for the tracked-key table. /// /// One constructor rather than an overload pair: two constructors both carrying optional parameters /// is ambiguous for callers, and the analyzers say so (RS0026). Named arguments cover the cases an /// overload would have. /// - public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) + public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) { - Policy = policy ?? CachePolicy.Default; + Options = options ?? CacheOptions.Default; _keys = new RespKeyTable(keyCapacity); } - /// How entries in this cache behave. - public CachePolicy Policy { get; } + /// How this cache is built: the settled-once decisions. + public CacheOptions Options { get; } + + /// How entries in this cache behave, unless a caller overrides it. + public CachePolicy Policy => Options.DefaultPolicy; /// Background refreshes started, because an entry was ageing but still servable. /// @@ -130,7 +133,7 @@ public RespClientCache(CachePolicy? policy = null, int keyCapacity = 256) public long RefusedNoKeys => Volatile.Read(ref _refusedNoKeys); /// - /// Fills refused because a key falls outside , so the server will + /// Fills refused because a key falls outside , so the server will /// never announce a change to it. /// /// @@ -392,7 +395,7 @@ private bool TryServeStale(Entry entry, out RespPayload? payload, out bool shoul /// write landing while the refresh is in flight must lose, not win. /// /// - /// No check, deliberately: a refresh only ever exists for an + /// No check, deliberately: a refresh only ever exists for an /// entry the first fill already admitted, and the policy is fixed for the life of the cache, so /// re-testing it would be work that cannot change the answer. /// @@ -530,11 +533,11 @@ public bool TryBeginFill(ref RespFrame frame, int database, CommandFlags flags, // EVERY key, not any: the entry depends on all of them, so one key the server was never asked // to watch is enough to make the whole reply uninvalidatable. MGET tracked untracked is not // "mostly fine". - if (Policy.HasPrefixes) + if (Options.HasPrefixes) { for (var i = 0; i < count; i++) { - if (!Policy.IsTracked(frame.GetKey(ranges[i]))) + if (!Options.IsTracked(frame.GetKey(ranges[i]))) { Interlocked.Increment(ref _refusedNotTracked); fill = default; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ceaca318a..a15d7149e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -33,7 +33,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedError.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(StackExchange.Redis.Interpolated.CachePolicy? policy = null, int keyCapacity = 256) -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(StackExchange.Redis.Interpolated.CacheOptions? options = null, int keyCapacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.Abandon() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void @@ -320,8 +320,6 @@ StackExchange.Redis.CommandFlagsExtensions static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackExchange.Redis.CommandFlags flags, StackExchange.Redis.CommandFlags category) -> StackExchange.Redis.CommandFlags [SER010]StackExchange.Redis.Interpolated.CachePolicy [SER010]StackExchange.Redis.Interpolated.CachePolicy.CachePolicy() -> void -[SER010]StackExchange.Redis.Interpolated.CachePolicy.Enabled.get -> bool -[SER010]StackExchange.Redis.Interpolated.CachePolicy.Enabled.init -> void [SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.get -> System.TimeSpan [SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.init -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.Expired.get -> long @@ -359,8 +357,16 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long [SER010]StackExchange.Redis.Interpolated.RespCacheConnectionExtensions [SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! -[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CachePolicy? +[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CacheOptions? [SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.set -> void -[SER010]StackExchange.Redis.Interpolated.CachePolicy.Prefixes.get -> System.Collections.Generic.IReadOnlyList! -[SER010]StackExchange.Redis.Interpolated.CachePolicy.Prefixes.init -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNotTracked.get -> long +[SER010]StackExchange.Redis.Interpolated.CacheOptions +[SER010]StackExchange.Redis.Interpolated.CacheOptions.CacheOptions() -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.get -> StackExchange.Redis.Interpolated.CachePolicy! +[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.get -> bool +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.get -> System.Collections.Generic.IReadOnlyList! +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.init -> void +[SER010]static StackExchange.Redis.Interpolated.CacheOptions.Default.get -> StackExchange.Redis.Interpolated.CacheOptions! +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Options.get -> StackExchange.Redis.Interpolated.CacheOptions! diff --git a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs index 304ab88c0..3f1bfbf51 100644 --- a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs +++ b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Threading.Tasks; using StackExchange.Redis.Interpolated; @@ -75,14 +75,14 @@ await WaitFor(async () => /// private async Task<(ConnectionMultiplexer Muxer, RespClientCache Cache)> TrackedAsync( string prefix, - CachePolicy? policy = null, + CacheOptions? cacheOptions = null, int? database = null) { var options = new ConfigurationOptions { EndPoints = { { TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort } }, Protocol = RedisProtocol.Resp3, - ClientCache = policy ?? new CachePolicy { Prefixes = [prefix] }, + ClientCache = cacheOptions ?? new CacheOptions { Prefixes = [prefix] }, DefaultDatabase = database, AllowAdmin = true, }; diff --git a/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs b/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs index f74b24471..ef7482ea3 100644 --- a/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs +++ b/tests/StackExchange.Redis.Tests/RespCacheLifetimeTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Threading; @@ -58,7 +58,7 @@ public async Task TheDefaultLifetimeIsFiniteRatherThanForever() [Fact] public async Task AnExpiredEntryIsNotServed() { - using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) }); + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = new RespContext().WithExecutor(executor).WithCache(cache); @@ -78,7 +78,7 @@ public async Task AContextCanDemandSomethingFresherThanThePolicy() { // the one knob that is per-call, because freshness tolerance is a property of the caller - and the // one that could not be added to IDatabase at all without a binary break - using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromHours(1) }); + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromHours(1) } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var relaxed = new RespContext().WithExecutor(executor).WithCache(cache); var picky = relaxed.WithMaxCacheAge(TimeSpan.FromMilliseconds(50)); @@ -100,7 +100,7 @@ public async Task OneEntryServesCallersWithDifferentTolerances() { // age is applied on READ, not stamped on store - so a single entry serves everybody, rather than // being duplicated once per distinct lifetime - using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromHours(1) }); + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromHours(1) } }); var executor = new CountingExecutor("$1\r\na\r\n"); var relaxed = new RespContext().WithExecutor(executor).WithCache(cache); var picky = relaxed.WithMaxCacheAge(TimeSpan.FromMinutes(30)); @@ -116,7 +116,7 @@ public async Task OneEntryServesCallersWithDifferentTolerances() public async Task AContextCannotAskForStalerThanThePolicyAllows() { // narrows, never widens: the deployment's lifetime is a ceiling - using var cache = new RespClientCache(new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) }); + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(80) } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = new RespContext().WithExecutor(executor).WithCache(cache) .WithMaxCacheAge(TimeSpan.FromHours(1)); diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index cd61edeb5..7582f7d94 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -867,7 +867,7 @@ public async Task ConcurrentInvalidationAndReadsNeverServeStale() [InlineData("", false)] public void UntrackedKeysAreNotCached(string key, bool cacheable) { - using var cache = new RespClientCache(new CachePolicy { Prefixes = ["app:", "session:"] }); + using var cache = new RespClientCache(new CacheOptions { Prefixes = ["app:", "session:"] }); var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); var admitted = cache.TryBeginFill(ref frame, 0, out var fill); @@ -896,7 +896,7 @@ public void UntrackedKeysAreNotCached(string key, bool cacheable) [Fact] public void OneUntrackedKeySpoilsAMultiKeyCommand() { - using var cache = new RespClientCache(new CachePolicy { Prefixes = ["app:"] }); + using var cache = new RespClientCache(new CacheOptions { Prefixes = ["app:"] }); var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"other"}"); Assert.False(cache.TryBeginFill(ref frame, 0, out _)); @@ -913,7 +913,7 @@ public void OneUntrackedKeySpoilsAMultiKeyCommand() [Fact] public void NoPrefixesMeansEverythingIsCacheable() { - using var cache = new RespClientCache(new CachePolicy()); // the default: BCAST with no prefix + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy() }); // the default: BCAST with no prefix var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"anything at all"}"); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); @@ -935,10 +935,10 @@ public void AnEmptyPrefixIsRejected() // ALONE, and checked by message. Paired with a real prefix it is caught by the overlap rule // instead - every string starts with "" - so that spelling passes even with this rule deleted, // which is exactly what it did until a mutant walked through it. - var ex = Assert.Throws(() => new CachePolicy { Prefixes = [""] }); + var ex = Assert.Throws(() => new CacheOptions { Prefixes = [""] }); Assert.Contains("matches every key", ex.Message); - Assert.Throws(() => new CachePolicy { Prefixes = ["app:", ""] }); + Assert.Throws(() => new CacheOptions { Prefixes = ["app:", ""] }); } /// @@ -951,18 +951,18 @@ public void AnEmptyPrefixIsRejected() [Fact] public void OverlappingPrefixesAreRejected() { - var ex = Assert.Throws(() => new CachePolicy { Prefixes = ["app:", "app:user:"] }); + var ex = Assert.Throws(() => new CacheOptions { Prefixes = ["app:", "app:user:"] }); Assert.Contains("must not overlap", ex.Message); // ...including a prefix repeated, which overlaps itself in the most literal way available - Assert.Throws(() => new CachePolicy { Prefixes = ["app:", "app:"] }); + Assert.Throws(() => new CacheOptions { Prefixes = ["app:", "app:"] }); } /// Prefix matching is on the bytes, so a multi-byte prefix is not matched by accident. [Fact] public void PrefixesMatchWholeBytesNotCharacters() { - using var cache = new RespClientCache(new CachePolicy { Prefixes = ["é:"] }); // 0xC3 0xA9 + using var cache = new RespClientCache(new CacheOptions { Prefixes = ["é:"] }); // 0xC3 0xA9 // a key starting with the first byte of the prefix but not the second must not match var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"è:x"}"); // 0xC3 0xA8 diff --git a/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs index fcddb4d03..77fcc32ae 100644 --- a/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs +++ b/tests/StackExchange.Redis.Tests/RespStaleWhileRevalidateTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Text; using System.Threading; @@ -60,11 +60,11 @@ private static RespContext Context(CountingExecutor executor, RespClientCache ca [Fact] public async Task AnAgeingEntryIsServedAndRefreshed() { - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { RefreshAfter = TimeSpan.FromMilliseconds(60), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -91,11 +91,11 @@ public async Task OnlyOneReaderRefreshes() { // without the claim, every reader past the threshold starts a refresh - the background work would // be the stampede it exists to prevent - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { RefreshAfter = TimeSpan.FromMilliseconds(50), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n"); var context = Context(executor, cache); @@ -112,11 +112,11 @@ public async Task OnlyOneReaderRefreshes() [Fact] public async Task AFreshEntryIsNotRefreshed() { - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { RefreshAfter = TimeSpan.FromMinutes(1), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n"); var context = Context(executor, cache); @@ -152,11 +152,11 @@ public async Task ReplacingAnEntryReleasesTheSupersededReply() { // the refresh swaps the value in place, and the reply it displaced holds a pooled buffer. Leaking // that reference would be invisible - the cache keeps working, it just never gives the buffer back. - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { RefreshAfter = TimeSpan.FromMilliseconds(50), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -185,11 +185,11 @@ public async Task AnInvalidatedEntryIsServedBrieflyAndRefreshed() { // the stampede that matters most: an invalidation lands for EVERY reader of a hot key at the same // instant, so time-based smoothing cannot help - the trigger was not time - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromSeconds(5), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -213,11 +213,11 @@ public async Task OurOwnWriteIsNeverServedThrough() // read-your-own-writes. "No observer can prove the order" excuses serving through somebody else's // write; it says nothing about ours, and returning the value the caller just replaced is reported // as corruption rather than as staleness. - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromSeconds(5), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -235,11 +235,11 @@ public async Task ALocalWriteStillCountsAfterAServerInvalidation() { // the two can arrive in either order - our own write echoes back from the server as well - and the // fact that WE wrote it must survive that - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromSeconds(5), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -273,11 +273,11 @@ public async Task TheWindowIsAlsoTheCap() { // on a hot-written key every refresh is invalidated before it can be stored, so without an absolute // bound this would serve stale for ever. Measured from FIRST NOTICE, so it cannot. - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromMilliseconds(80), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); // The refresh must NOT be allowed to succeed, or it heals the entry and the test cannot tell the cap // from the cure. An error reply is refused by TryComplete, so the entry stays invalid - which is // precisely the hot-written-key situation the cap is for: every refresh is lost, and without a bound @@ -309,11 +309,11 @@ public async Task AKeyNobodyIsReadingJustExpires() // invalidated. A key nobody is reading should simply expire - starting the clock at first notice // would instead resurrect it for whoever wandered past an hour later, which is the opposite of the // intent. - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromMilliseconds(80), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); @@ -333,11 +333,11 @@ public async Task TheGraceIsNotRestartedByLaterReads() { // it is a grace period, not a sliding window: constant access bridges the burst, it does not keep // the old value alive indefinitely - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { InvalidationGracePeriod = TimeSpan.FromMilliseconds(120), TimeToLive = TimeSpan.FromMinutes(5), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "-ERR not today\r\n", "$1\r\nc\r\n"); var context = Context(executor, cache); @@ -371,11 +371,11 @@ private static RespRequest RenderKey(RespContext context) public async Task AThresholdBeyondTheLifetimeNeverFires() { // it could never be crossed: the entry expires first. Treated as "off" rather than as a puzzle. - using var cache = new RespClientCache(new CachePolicy + using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy { RefreshAfter = TimeSpan.FromMinutes(10), TimeToLive = TimeSpan.FromMilliseconds(80), - }); + } }); var executor = new CountingExecutor("$1\r\na\r\n", "$1\r\nb\r\n"); var context = Context(executor, cache); From e2d2ea3c205bc0a5623d27d8db69682b1d13a5d0 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 22:41:46 +0100 Subject: [PATCH 133/360] Bound one entry, and make the sweep actually run Two gaps, both of the same shape as the cache having no host: machinery built and tested against fakes, with nothing in src driving it. Sweep() had no caller outside tests, so nothing ever reclaimed a dead entry. It now runs from the multiplexer heartbeat, with the cadence inside the cache so the driver does not need to know its business and a test can call it directly. SweepInterval is a cost knob, not a correctness one: a dead entry is already refused on read. Sweep also only dropped INVALIDATED entries. An entry that merely aged out is refused when read - and for a key nothing comes back for, nothing reads it, so nothing removed it. That is exactly the entry a lifetime cannot help with, because nobody is there to notice it has passed. MaxPayloadBytes is the cheapest bound there is - the reply's size is known before anything is stored - and large replies are both the fastest way to spend a memory budget and the least likely to be read again. Default 1MiB; null for no limit; refusals counted, because a reply silently not being cached should be answerable without a debugger. One mutant survived and is recorded rather than hidden: claiming the sweep timestamp after the work rather than before. The compare-exchange is a cost property, since overlapping sweeps are already safe - only the caller that wins TryRemove disposes anything - and the window is too narrow to assert on. The test now pins the safety it can prove instead of the collapse it cannot. --- design/interpolated-resp-writer.queue.md | 16 +- .../ConnectionMultiplexer.cs | 4 + .../Interpolated/CacheOptions.cs | 56 ++++++- .../Interpolated/RespClientCache.cs | 72 +++++++- .../PublicAPI/PublicAPI.Unshipped.txt | 6 + .../RespClientCacheTests.cs | 154 ++++++++++++++++++ 6 files changed, 300 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 89ed14b2c..b92615e91 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -65,6 +65,18 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Later / decide first +- [ ] **Byte quota and eviction.** `MaxPayloadBytes` bounds one entry; nothing yet bounds the total. Count + the *buffer*, not the payload: each reply is copied into its own `ArrayPool.Shared` rent, and + the shared pool rounds to power-of-two buckets, so accounting on payload length under-counts by up to + ~2x - which is the error that makes a quota fail to bind under the workload that most needs it. + `RefCountedBuffer.Length` is the honest number. A secondary entry-count cap is worth having too, + since the key table grows independently of payload bytes. + On eviction policy: **LRU would tax the one path that is currently free.** A hit today is a dictionary + lookup plus a refcount bump; recency tracking adds a write to every read. Redis approximates LRU by + sampling for exactly this reason, and the same answer is available here. Whatever the policy, eviction + must release exactly its own reference while readers hold theirs - `Release()` is a bare decrement + with no idempotence guard, and `TryRemove` does not hand back the stored key. + - [ ] **Per-context `CachePolicy` override** (`WithCachePolicy`). The other half of the options/policy split: policy settings are read-time, so they can vary per call, and the override rides in the context's service slot exactly as `MaxCacheAgeService` does. `WithMaxCacheAge` stays as the @@ -110,7 +122,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". invalidation pushes to it through `PhysicalConnection` — `4d608ddd` - [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` - [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` -- [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — this change +- [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` +- [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and + `Sweep` reclaiming expired entries rather than only invalidated ones — this change ## Decided against diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 4cc495295..56f90522c 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1361,6 +1361,10 @@ internal void OnHeartbeat() Interlocked.Exchange(ref lastGlobalHeartbeatTicks, now); Trace("heartbeat"); + // dead cache entries hold their memory until something comes back for them; nothing does, + // for a key that is never read again. The cache decides whether it is actually due. + ClientCache?.SweepIfDue(); + var tmp = GetServerSnapshot(); int token = 0; bool isRooted = pulse?.IsRooted(out token) ?? false, hasPendingCallerFacingItems = false; diff --git a/src/StackExchange.Redis/Interpolated/CacheOptions.cs b/src/StackExchange.Redis/Interpolated/CacheOptions.cs index 733994cdb..d93906cac 100644 --- a/src/StackExchange.Redis/Interpolated/CacheOptions.cs +++ b/src/StackExchange.Redis/Interpolated/CacheOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -41,6 +41,60 @@ public sealed class CacheOptions /// How entries behave, unless a caller says otherwise. public CachePolicy DefaultPolicy { get; init; } = CachePolicy.Default; + /// + /// The largest reply that may be cached; for no limit. + /// + /// + /// + /// The cheapest bound available, and the one that binds first. It needs no bookkeeping at + /// all - the reply's size is known before anything is stored - and large replies are both the ones + /// that consume a memory budget fastest and, typically, the ones least likely to be read again. + /// + /// + /// Measured against the reply as the server sent it. What an entry actually costs is a little + /// more, because each reply is copied into its own array from + /// and the shared pool rounds up to power-of-two buckets - a 33-byte reply pins 64. That rounding is + /// the quota's business; this is a limit a human sets, so it reads in the units a human has. + /// + /// + /// Refusals are counted as , because a reply silently + /// not being cached is exactly the kind of thing that should be answerable without a debugger. + /// + /// + public int? MaxPayloadBytes + { + get => _maxPayloadBytes; + init => _maxPayloadBytes = value is null or > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "The maximum payload size must be positive, or null for no limit."); + } + + private readonly int? _maxPayloadBytes = 1024 * 1024; + + /// + /// How often dead entries are reclaimed; or less to never sweep. + /// + /// + /// + /// Invalidation deliberately does no work beyond stamping a generation, and expiry is decided when + /// an entry is read - so an entry that was invalidated, or that simply aged out, holds its memory + /// until something comes back for it. For a key that is never read again, that is forever. This is + /// what comes back for it. + /// + /// + /// A cadence rather than a deadline: nothing about correctness depends on it, since a dead entry is + /// already refused on read. It is purely when the memory returns, which is why the default is + /// unhurried. + /// + /// + public TimeSpan SweepInterval { get; init; } = TimeSpan.FromSeconds(10); + + /// Whether asks for sweeping at all. + internal bool Sweeps => SweepInterval > TimeSpan.Zero; + + /// as a tick count. + internal long SweepIntervalTicks => CachePolicy.ToTicks(SweepInterval); + /// /// The key prefixes this connection asks the server to track; empty means all keys. /// diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index d6a809b65..eb3324d75 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -53,6 +53,8 @@ public sealed class RespClientCache : IDisposable private long _refusedByFlags; private long _refusedNoKeys; private long _refusedNotTracked; + private long _refusedTooLarge; + private long _lastSweep = Stopwatch.GetTimestamp(); private long _refusedRaced; private long _redundantFills; private long _refusedError; @@ -151,6 +153,14 @@ public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) /// Fills refused because an invalidation landed while the command was in flight. public long RefusedRaced => Volatile.Read(ref _refusedRaced); + /// Fills refused because the reply was larger than . + /// + /// Worth watching in both directions. Rising steadily means the limit is doing its job. Rising for + /// the same request over and over means a round trip is being paid every time for something + /// that would happily be cached with a slightly larger limit. + /// + public long RefusedTooLarge => Volatile.Read(ref _refusedTooLarge); + /// /// Fills that completed only to find the same request already cached by someone else - i.e. two or /// more callers missed on the same request concurrently and all of them went to the server. @@ -687,6 +697,14 @@ private bool TryCompleteCore(in RespFill fill, RespPayload response) return false; } + // last of the refusals, so this counter only ever means "nothing else was wrong with it" + if (Options.MaxPayloadBytes is int max && response.Span.Length > max) + { + Interlocked.Increment(ref _refusedTooLarge); + fill.Key.Dispose(); + return false; + } + if (!fill.Key.TryRetain(out var stored)) { fill.Key.Dispose(); @@ -735,22 +753,33 @@ private bool TryCompleteCore(in RespFill fill, RespPayload response) } /// - /// Drop entries that no longer validate, releasing their payloads and keys. + /// Drop entries that can no longer be served, releasing their payloads and keys. /// /// The number of entries removed. /// - /// Invalidation deliberately does no work beyond stamping a generation, so this is where the memory - /// actually comes back. It is O(entries) and belongs on a timer, not on the invalidation path. + /// + /// Invalidation deliberately does no work beyond stamping a generation, and expiry is decided when + /// an entry is read, so this is where the memory actually comes back. It is O(entries) and + /// belongs on a timer, not on either of those paths. + /// + /// + /// Both kinds of dead entry, not only invalidated ones. An entry that simply aged out is + /// refused on read but never removed by reading, so for a key nothing comes back for it stays + /// resident for ever - which is the case a lifetime is least able to help with, since nobody is + /// there to notice it has passed. + /// /// public int Sweep() { var removed = 0; + var lifetime = Policy.TimeToLiveTicks; foreach (var pair in _entries) { - if (pair.Value.IsValid) continue; - if (_entries.TryRemove(pair.Key, out var entry)) + var entry = pair.Value; + if (entry.IsValid && !CachePolicy.IsOlderThan(entry.FilledAt, lifetime)) continue; + if (_entries.TryRemove(pair.Key, out var removing)) { - entry.Payload.Dispose(); + removing.Payload.Dispose(); pair.Key.Frame.Dispose(); removed++; } @@ -759,6 +788,37 @@ public int Sweep() return removed; } + /// + /// Sweep, but only if has elapsed since the last one. + /// + /// The number of entries removed; zero if it was not yet due. + /// + /// + /// The cadence lives here rather than in whatever is driving it, so the driver - today the + /// multiplexer heartbeat - does not have to know the cache's business, and a test can call this + /// directly instead of waiting on a timer. + /// + /// + /// The timestamp is claimed with a compare-exchange before the work starts, so two drivers + /// arriving together produce one sweep rather than two, and a sweep that overruns its interval is + /// not restarted by every tick it overran. This is a cost property, not a correctness one: + /// overlapping sweeps are already safe, because the removal is a TryRemove and only the + /// caller that wins it disposes anything. Said plainly because the concurrency test below can + /// demonstrate the safety but not reliably the collapse - the window is microseconds wide. + /// + /// + public int SweepIfDue() + { + if (!Options.Sweeps) return 0; + + var last = Volatile.Read(ref _lastSweep); + var now = Stopwatch.GetTimestamp(); + if (now - last < Options.SweepIntervalTicks) return 0; + if (Interlocked.CompareExchange(ref _lastSweep, now, last) != last) return 0; + + return Sweep(); + } + /// Release every cached payload and key. public void Dispose() { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index a15d7149e..e6deb620a 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -370,3 +370,9 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.init -> void [SER010]static StackExchange.Redis.Interpolated.CacheOptions.Default.get -> StackExchange.Redis.Interpolated.CacheOptions! [SER010]StackExchange.Redis.Interpolated.RespClientCache.Options.get -> StackExchange.Redis.Interpolated.CacheOptions! +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.get -> int? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedTooLarge.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.SweepIfDue() -> int diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 7582f7d94..782a09e4a 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1032,4 +1032,158 @@ public async Task FireAndForgetIsNotServedAsynchronouslyEither() Assert.Equal(1, cache.Count); // and it did not disturb what was already there } + /// A reply larger than the limit is refused, and one at the limit is kept. + /// + /// The cheapest bound there is: the reply's size is known before anything is stored, so this costs no + /// bookkeeping at all. Boundary included deliberately - "larger than" and "at least" differ by exactly + /// the case a limit is most often written wrongly for. + /// + [Theory] + [InlineData(4, false)] // "$1\r\nx\r\n" is 7 bytes + [InlineData(7, true)] + [InlineData(8, true)] + public void RepliesOverTheSizeLimitAreRefused(int limit, bool cacheable) + { + using var cache = new RespClientCache(new CacheOptions { MaxPayloadBytes = limit }); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.Equal(cacheable, Complete(cache, fill, "$1\r\nx\r\n")); + Assert.Equal(cacheable ? 1 : 0, cache.Count); + Assert.Equal(cacheable ? 0 : 1, cache.RefusedTooLarge); + } + + /// With no limit set, size is not a reason to refuse. + [Fact] + public void NoSizeLimitMeansNoSizeRefusals() + { + using var cache = new RespClientCache(new CacheOptions { MaxPayloadBytes = null }); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + Assert.Equal(0, cache.RefusedTooLarge); + } + + /// A size limit must be positive; null is how you say "no limit". + [Fact] + public void ANonPositiveSizeLimitIsRejected() + { + Assert.Throws(() => new CacheOptions { MaxPayloadBytes = 0 }); + Assert.Throws(() => new CacheOptions { MaxPayloadBytes = -1 }); + } + + /// + /// Sweeping reclaims entries that merely expired, not only invalidated ones. + /// + /// + /// Expiry is decided when an entry is read, so reading is what refuses it - and for a key nothing ever + /// comes back for, nothing ever reads it, so nothing ever removes it. That is precisely the entry a + /// lifetime cannot help with, because nobody is there to notice it has passed. + /// + [Fact] + public async Task SweepReclaimsExpiredEntriesAndNotOnlyInvalidatedOnes() + { + using var cache = new RespClientCache(new CacheOptions + { + DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(30) }, + }); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + Assert.Equal(1, cache.Count); + + // still live: nothing to reclaim, and nobody has invalidated it + Assert.Equal(0, cache.Sweep()); + Assert.Equal(1, cache.Count); + + await Task.Delay(80); + Assert.Equal(1, cache.Sweep()); + Assert.Equal(0, cache.Count); + } + + /// A sweep that is not yet due does nothing; one that is, sweeps. + [Fact] + public async Task SweepIfDueHonoursTheInterval() + { + using var cache = new RespClientCache(new CacheOptions + { + SweepInterval = TimeSpan.FromMilliseconds(50), + DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(10) }, + }); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + + await Task.Delay(20); // expired, but the sweep is not due yet + Assert.Equal(0, cache.SweepIfDue()); + Assert.Equal(1, cache.Count); + + await Task.Delay(60); + Assert.Equal(1, cache.SweepIfDue()); + Assert.Equal(0, cache.Count); + } + + /// Sweeping can be turned off entirely. + [Fact] + public async Task ASweepIntervalOfZeroNeverSweeps() + { + using var cache = new RespClientCache(new CacheOptions + { + SweepInterval = TimeSpan.Zero, + DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(10) }, + }); + + var frame = Get("abc"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + + await Task.Delay(40); + Assert.Equal(0, cache.SweepIfDue()); + Assert.Equal(1, cache.Count); + + // ...but an explicit sweep still works: the interval governs the driver, not the operation + Assert.Equal(1, cache.Sweep()); + } + + /// + /// Concurrent sweeps reclaim every entry exactly once, and never twice. + /// + /// + /// What this can and cannot pin. The compare-exchange in SweepIfDue is there so two + /// drivers arriving together do one sweep rather than two - but that is a cost property, and the window + /// is microseconds wide, so asserting the collapse would be asserting a race. A mutant that claimed the + /// timestamp after the work instead of before duly survived that assertion. What is worth pinning, and + /// is deterministic, is the safety underneath it: whatever the interleaving, each entry is removed by + /// exactly one caller and disposed exactly once - a double release would return a live buffer to the + /// pool, which is the failure that actually costs something. + /// + [Fact] + public async Task ConcurrentSweepsReclaimEachEntryExactlyOnce() + { + using var cache = new RespClientCache(new CacheOptions + { + SweepInterval = TimeSpan.FromMilliseconds(10), + DefaultPolicy = new CachePolicy { TimeToLive = TimeSpan.FromMilliseconds(5) }, + }); + + for (var i = 0; i < 20; i++) + { + var frame = Get("key" + i); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + } + + await Task.Delay(40); + + var tasks = new Task[8]; + for (var i = 0; i < tasks.Length; i++) tasks[i] = Task.Run(cache.SweepIfDue); + var removed = await Task.WhenAll(tasks); + + Assert.Equal(20, removed.Sum()); // every entry reclaimed, and none of them twice + Assert.Equal(0, cache.Count); + } + } From 728e91028cd2952294e2015b1c0840d186d13b74 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 23:12:07 +0100 Subject: [PATCH 134/360] CacheTrackingMode: broadcast or per-key, chosen rather than assumed The two modes are a trade, not a ranking: broadcasting costs the client noise, per-key tracking costs the server memory, and which is cheaper depends on whose resource is scarce - not something a library can know. An enum rather than a bool because the server has two further modes, OPTIN and OPTOUT, and a boolean cannot grow to hold them. They are absent rather than present-and-throwing for an honest reason: both are driven by CLIENT CACHING YES|NO applying to the next command on that connection, which a multiplexer gives a caller no way to control. Offering them needs the pipeline to write the pair atomically, as it already does for a transaction. The enum earns its keep immediately: PREFIX is broadcast-only, so the two settings constrain one another. That check cannot live in an init accessor - an object initializer assigns in the order the caller wrote, so the rule would pass or fail on line ordering - so it runs when the options are first used for something, which is the only point the whole object exists. --- design/interpolated-resp-writer.queue.md | 8 ++- .../Interpolated/CacheOptions.cs | 39 +++++++++++ .../Interpolated/CacheTrackingMode.cs | 64 +++++++++++++++++++ .../Interpolated/RespClientCache.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 5 ++ .../RespClientCacheTests.cs | 40 ++++++++++++ 6 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/CacheTrackingMode.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index b92615e91..dab007d67 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -37,8 +37,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". **composability**: `IRespHandler` built from `IRespHandler`. Cheapest while handlers live in one file. Mechanical: delete two lines per handler, take the parameter. -- [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only, `BCAST`, empty prefix by default - (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without +- [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only; the mode and prefixes now come + from `CacheOptions.TrackingMode` / `CacheOptions.Prefixes`, which are already validated against each + other (§6.13). Must refuse **loudly** when RESP3 is unavailable rather than silently caching without invalidation, and the `PREFIX` arguments must come from `CachePolicy.Prefixes` rather than a second list — the cache already refuses keys outside that set, so the two drifting apart would mean either caching what nothing announces, or refusing what something does. @@ -123,8 +124,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` - [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` - [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` +- [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — this change - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and - `Sweep` reclaiming expired entries rather than only invalidated ones — this change + `Sweep` reclaiming expired entries rather than only invalidated ones — `e2d2ea3c` ## Decided against diff --git a/src/StackExchange.Redis/Interpolated/CacheOptions.cs b/src/StackExchange.Redis/Interpolated/CacheOptions.cs index d93906cac..332c879a4 100644 --- a/src/StackExchange.Redis/Interpolated/CacheOptions.cs +++ b/src/StackExchange.Redis/Interpolated/CacheOptions.cs @@ -38,6 +38,14 @@ public sealed class CacheOptions /// public bool Enabled { get; init; } = true; + /// How the server decides which keys to tell us about. + /// + /// by default: it costs the server nothing to remember, + /// and what it costs us - hearing about keys we never asked for - is the part we can bound, with + /// . + /// + public CacheTrackingMode TrackingMode { get; init; } = CacheTrackingMode.Broadcast; + /// How entries behave, unless a caller says otherwise. public CachePolicy DefaultPolicy { get; init; } = CachePolicy.Default; @@ -123,6 +131,12 @@ public int? MaxPayloadBytes /// cacheable. An empty or null entry is not a way to spell that; it is rejected, because /// "" matches everything and would silently turn a narrow list into a total one. /// + /// + /// Broadcast only. PREFIX is meaningless under + /// - the server announces what we read, so there is nothing + /// to filter - and CLIENT TRACKING rejects the combination outright. Setting both is an + /// error, raised when the cache is built rather than at the handshake. + /// /// public IReadOnlyList Prefixes { @@ -138,8 +152,33 @@ public IReadOnlyList Prefixes private readonly byte[][] _prefixBytes = []; /// Whether restricts what may be cached. + /// + /// Only ever true in : under + /// the server announces exactly what we read, so there is no + /// such thing as an untracked key and nothing for the gate to refuse. + /// internal bool HasPrefixes => _prefixBytes.Length != 0; + /// + /// Check settings that constrain one another; called when a cache is built from these options. + /// + /// + /// Not in the init accessors, and not because it would be inconvenient there: an object + /// initializer assigns in whatever order the caller wrote, so a rule spanning two properties + /// would pass or fail depending on which line came first. Checking once, when the options are + /// actually used for something, is the only place the whole object exists. + /// + internal void Validate() + { + if (HasPrefixes && TrackingMode != CacheTrackingMode.Broadcast) + { + throw new ArgumentException( + $"Cache prefixes require {nameof(CacheTrackingMode)}.{nameof(CacheTrackingMode.Broadcast)};" + + $" {TrackingMode} announces the keys that were read, so there is nothing to filter.", + nameof(Prefixes)); + } + } + /// /// Whether a key is inside the tracked set, and so has something that can invalidate it. /// diff --git a/src/StackExchange.Redis/Interpolated/CacheTrackingMode.cs b/src/StackExchange.Redis/Interpolated/CacheTrackingMode.cs new file mode 100644 index 000000000..4f26c1721 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/CacheTrackingMode.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. How the server decides which keys to tell us about. + /// + /// + /// + /// The two modes of CLIENT TRACKING, and they are a trade rather than a ranking: broadcasting + /// costs the client noise, and per-key tracking costs the server memory. Which is cheaper depends on + /// whose resource is scarce, which is not something a library can know. + /// + /// + /// An enum rather than a bool Broadcast because the server has two further modes - + /// OPTIN and OPTOUT - and a boolean cannot grow to hold them. Whether they will ever be + /// offered here is genuinely open; see . + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public enum CacheTrackingMode + { + /// + /// BCAST: the server announces every changed key matching + /// , whoever changed it, without remembering what we read. + /// + /// + /// + /// The default, because the cost lands on the side that can see it. The server keeps no per-client + /// key table at all, so it cannot run out of room for one; what we pay instead is being told about + /// keys we never asked for, which exists to bound. + /// + /// + /// The consequence that reaches the cache: a key outside the prefixes is never announced, so it is + /// never cached either - see . + /// + /// + Broadcast = 0, + + /// + /// Default mode: the server remembers which keys we read, and announces only those. + /// + /// + /// + /// Precise - no key we did not ask about is ever mentioned - and + /// is meaningless here, because there is nothing to filter. The cost moves to the server, which must + /// hold a table of keys per client and evicts from it under pressure + /// (tracking-table-max-keys), announcing what it drops. + /// + /// + /// Two hazards specific to this mode. The server stops tracking a key once it has told us + /// about it, so anything that reads without re-registering goes quietly stale - which is also why + /// NOLOOP is not simply switched on (design notes 6.13). And OPTIN/OPTOUT, the + /// two refinements that only exist here, are driven by CLIENT CACHING YES|NO applying to the + /// next command on that connection - which a multiplexer does not give a caller any way to + /// control. Offering them would need the pipeline to write the pair atomically, the way it already + /// does for a transaction; until that exists they are not on the table, which is the honest reason + /// they are absent from this enum rather than present and throwing. + /// + /// + PerKey = 1, + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index eb3324d75..bb9f333f4 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -74,6 +74,7 @@ public sealed class RespClientCache : IDisposable public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) { Options = options ?? CacheOptions.Default; + Options.Validate(); // settings that constrain one another; see CacheOptions.Validate _keys = new RespKeyTable(keyCapacity); } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e6deb620a..faa8a249e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -376,3 +376,8 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.init -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedTooLarge.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.SweepIfDue() -> int +[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.get -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.Broadcast = 0 -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.PerKey = 1 -> StackExchange.Redis.Interpolated.CacheTrackingMode diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 782a09e4a..60cf1cf89 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1186,4 +1186,44 @@ public async Task ConcurrentSweepsReclaimEachEntryExactlyOnce() Assert.Equal(0, cache.Count); } + /// + /// Prefixes require broadcast: under per-key tracking there is nothing to filter. + /// + /// + /// Raised when the cache is built rather than from an init accessor, because an object + /// initializer assigns in whatever order the caller wrote it - so a rule spanning two properties would + /// pass or fail on line ordering. The message names both halves, since either one could be the mistake. + /// + [Fact] + public void PrefixesRequireBroadcastTracking() + { + var options = new CacheOptions + { + TrackingMode = CacheTrackingMode.PerKey, + Prefixes = ["app:"], + }; + + var ex = Assert.Throws(() => new RespClientCache(options)); + Assert.Contains("Broadcast", ex.Message); + + // ...and the same list is fine the other way round, whichever order it was written in + using var ok = new RespClientCache(new CacheOptions { Prefixes = ["app:"], TrackingMode = CacheTrackingMode.Broadcast }); + using var alsoOk = new RespClientCache(new CacheOptions { TrackingMode = CacheTrackingMode.Broadcast, Prefixes = ["app:"] }); + } + + /// + /// Under per-key tracking every key we read is tracked by definition, so nothing is refused for being + /// outside a set. + /// + [Fact] + public void PerKeyTrackingCachesAnyKey() + { + using var cache = new RespClientCache(new CacheOptions { TrackingMode = CacheTrackingMode.PerKey }); + + var frame = Get("anything at all"); + Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + Assert.Equal(0, cache.RefusedNotTracked); + } + } From 87d5afa2da30502b6042b61cc6aa483474ac924e Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 23:17:40 +0100 Subject: [PATCH 135/360] Bound the cache: byte and entry quotas, with sampled eviction MaxPayloadBytes bounded one entry; nothing bounded the total, so a cache of many small replies grew without limit - and each entry pins a pooled array, so the memory is not only heap. Counted as memory HELD, not bytes carried. Each reply is copied into its own rent from ArrayPool.Shared, which serves from power-of-two buckets, so a 33-byte reply holds 64. Budgeting on payload lengths would under-report by up to a factor of two - the error that lets a quota fail to bind under exactly the workload that needed it to. MaxEntries is not redundant with MaxBytes: many tiny replies spend almost no memory on payloads while still growing the entry and tracked-key tables, whose cost a byte budget cannot see. Eviction samples rather than tracking recency. True LRU needs the moment of last use, which means a write on every READ - and a read is the one path here that is currently free: a dictionary lookup and a refcount bump. Redis reached the same conclusion about its own keyspace. Two details that are easy to get subtly wrong, both now commented: the sample start is chosen so a whole sample is always available, since stopping at the end of the enumeration under-samples the front of the table; and the cursor advances per call rather than coming from the clock, because a burst of evictions outruns Environment.TickCount and would be handed the same window over and over. Dead entries are taken before live ones. The sample is walked anyway, so an already-invalidated entry costs nothing to release and, unlike a live one, nobody wanted it. Eviction runs after the store, so a budget is a target the cache returns to rather than a wall - briefly overshot by one entry. Refusing instead would throw away a reply already paid for in full. The loop is bounded by the entry count rather than by "until it fits": under concurrent stores, evicting for ever is a worse failure than being briefly over budget. Byte accounting funnels through one release method, called only by whoever won the TryRemove, so the count cannot drift and a payload cannot be released twice - which would hand a live buffer back to the pool. --- design/interpolated-resp-writer.queue.md | 15 +- .../Interpolated/CacheOptions.cs | 82 ++++++++ .../Interpolated/RespClientCache.cs | 170 ++++++++++++++++- .../Interpolated/RespPayload.cs | 11 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 8 + .../RespClientCacheTests.cs | 177 ++++++++++++++++++ 6 files changed, 444 insertions(+), 19 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index dab007d67..b42e645ee 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -66,18 +66,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Later / decide first -- [ ] **Byte quota and eviction.** `MaxPayloadBytes` bounds one entry; nothing yet bounds the total. Count - the *buffer*, not the payload: each reply is copied into its own `ArrayPool.Shared` rent, and - the shared pool rounds to power-of-two buckets, so accounting on payload length under-counts by up to - ~2x - which is the error that makes a quota fail to bind under the workload that most needs it. - `RefCountedBuffer.Length` is the honest number. A secondary entry-count cap is worth having too, - since the key table grows independently of payload bytes. - On eviction policy: **LRU would tax the one path that is currently free.** A hit today is a dictionary - lookup plus a refcount bump; recency tracking adds a write to every read. Redis approximates LRU by - sampling for exactly this reason, and the same answer is available here. Whatever the policy, eviction - must release exactly its own reference while readers hold theirs - `Release()` is a bare decrement - with no idempotence guard, and `TryRemove` does not hand back the stored key. - - [ ] **Per-context `CachePolicy` override** (`WithCachePolicy`). The other half of the options/policy split: policy settings are read-time, so they can vary per call, and the override rides in the context's service slot exactly as `MaxCacheAgeService` does. `WithMaxCacheAge` stays as the @@ -124,7 +112,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` - [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` - [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` -- [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — this change +- [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` +- [x] Byte and entry quotas, with sampled eviction — this change - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and `Sweep` reclaiming expired entries rather than only invalidated ones — `e2d2ea3c` diff --git a/src/StackExchange.Redis/Interpolated/CacheOptions.cs b/src/StackExchange.Redis/Interpolated/CacheOptions.cs index 332c879a4..c677979b1 100644 --- a/src/StackExchange.Redis/Interpolated/CacheOptions.cs +++ b/src/StackExchange.Redis/Interpolated/CacheOptions.cs @@ -79,6 +79,88 @@ public int? MaxPayloadBytes private readonly int? _maxPayloadBytes = 1024 * 1024; + /// + /// The most memory cached replies may hold; for no limit. + /// + /// + /// + /// Counted as memory held, not bytes carried. Each reply is copied into its own rent from + /// , and the shared pool serves from power-of-two buckets, + /// so a 33-byte reply holds 64. Budgeting on payload lengths would under-report by up to a factor + /// of two - which is the error that lets a quota fail to bind under exactly the workload that + /// needed it to. + /// + /// + /// Enforced after a store rather than before: whether an entry fits is not knowable until the reply + /// has arrived, and refusing it at that point would throw away something already paid for in full. + /// So it is stored, and the cache then evicts down to the budget - which also means the budget is a + /// target the cache returns to, briefly overshot, rather than a wall. + /// + /// + /// is the default and means unbounded, which is the honest description of + /// what a client-side cache is without one. Pair it with : bytes do not + /// bound the tracked-key table, which grows with the number of distinct requests rather than their + /// size. + /// + /// + public long? MaxBytes + { + get => _maxBytes; + init => _maxBytes = value is null or > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "The memory budget must be positive, or null for no limit."); + } + + private readonly long? _maxBytes; + + /// + /// The most entries that may be cached; for no limit. + /// + /// + /// The companion to , and not redundant with it: a workload of many tiny + /// replies spends almost no memory on payloads while still growing the entry table and the + /// tracked-key table, whose costs a byte budget cannot see. + /// + public int? MaxEntries + { + get => _maxEntries; + init => _maxEntries = value is null or > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "The entry limit must be positive, or null for no limit."); + } + + private readonly int? _maxEntries; + + /// Whether either budget is set. + internal bool HasBudget => _maxBytes is not null || _maxEntries is not null; + + /// + /// How many entries are examined when choosing what to evict. + /// + /// + /// + /// Sampled, not exact, and deliberately so. True LRU needs the moment of last use, which + /// means a write on every read - and a read is the one path in this cache that is currently + /// free: a dictionary lookup and a reference-count bump, nothing else. Paying for eviction on every + /// hit to make eviction slightly better is the wrong trade, and Redis reached the same conclusion + /// about its own keyspace, approximating LRU by sampling rather than maintaining it. + /// + /// + /// So eviction samples this many entries and takes the oldest of them, by fill time. A larger + /// sample is a closer approximation at proportionally more work, and the work happens on eviction - + /// which is already the expensive path - rather than on every hit. + /// + /// + public int EvictionSampleSize + { + get => _evictionSampleSize; + init => _evictionSampleSize = value > 0 + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "The eviction sample size must be positive."); + } + + private readonly int _evictionSampleSize = 8; + /// /// How often dead entries are reclaimed; or less to never sweep. /// diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index bb9f333f4..97f3641fc 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -54,6 +54,9 @@ public sealed class RespClientCache : IDisposable private long _refusedNoKeys; private long _refusedNotTracked; private long _refusedTooLarge; + private long _evicted; + private long _bytes; + private int _evictCursor; private long _lastSweep = Stopwatch.GetTimestamp(); private long _refusedRaced; private long _redundantFills; @@ -154,6 +157,24 @@ public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) /// Fills refused because an invalidation landed while the command was in flight. public long RefusedRaced => Volatile.Read(ref _refusedRaced); + /// Entries dropped to stay inside or . + /// + /// Distinct from and from a sweep: nothing was wrong with these entries, there + /// was simply not room. Rising alongside a healthy hit rate means the budget is the binding + /// constraint rather than the data's lifetime, which is a different conversation from "why is + /// nothing being cached". + /// + public long Evicted => Volatile.Read(ref _evicted); + + /// + /// The memory currently held by cached replies. + /// + /// + /// What the entries actually hold, not what they carry: replies live in pooled arrays that round up + /// to the pool's bucket sizes. See . + /// + public long Bytes => Volatile.Read(ref _bytes); + /// Fills refused because the reply was larger than . /// /// Worth watching in both directions. Rising steadily means the limit is doing its job. Rising for @@ -727,21 +748,26 @@ private bool TryCompleteCore(in RespFill fill, RespPayload response) // stays owned by the dictionary. TryRemove hands back the value but NOT the stored key, so // removing would strand that key's reference - and disposing our own copy instead would release // the wrong one. + var entry = new Entry(response, fill.Dependencies); if (fill.Replaces && _entries.TryGetValue(entryKey, out var previous) - && _entries.TryUpdate(entryKey, new Entry(response, fill.Dependencies), previous)) + && _entries.TryUpdate(entryKey, entry, previous)) { + Interlocked.Add(ref _bytes, entry.Bytes - previous.Bytes); previous.Payload.Dispose(); // the superseded reply stored.Dispose(); // our key copy was spare; the dictionary kept its own fill.Key.Dispose(); Interlocked.Increment(ref _stored); + EvictToBudget(); return true; } - if (_entries.TryAdd(entryKey, new Entry(response, fill.Dependencies))) + if (_entries.TryAdd(entryKey, entry)) { + Interlocked.Add(ref _bytes, entry.Bytes); fill.Key.Dispose(); // the dictionary holds its own references now Interlocked.Increment(ref _stored); + EvictToBudget(); return true; } @@ -780,8 +806,7 @@ public int Sweep() if (entry.IsValid && !CachePolicy.IsOlderThan(entry.FilledAt, lifetime)) continue; if (_entries.TryRemove(pair.Key, out var removing)) { - removing.Payload.Dispose(); - pair.Key.Frame.Dispose(); + Release(pair.Key, removing); removed++; } } @@ -820,14 +845,139 @@ public int SweepIfDue() return Sweep(); } + /// + /// Let go of an entry that has already been removed from the table: its payload, its key, and its + /// share of the budget. + /// + /// + /// One place, called only by whoever won the TryRemove, so the byte count cannot drift and a + /// payload cannot be released twice - which would hand a live buffer back to the pool, since + /// Release() is a bare decrement with no idempotence guard. + /// + private void Release(in EntryKey key, Entry entry) + { + Interlocked.Add(ref _bytes, -entry.Bytes); + entry.Payload.Dispose(); + key.Frame.Dispose(); + } + + /// + /// Drop entries until the cache is back inside and + /// . + /// + /// The number of entries dropped. + /// + /// + /// Sampled, oldest-of-the-sample. True LRU needs the moment of last use, which means writing + /// to an entry on every read - and a read is the one path here that is currently free. Redis + /// approximates its own keyspace LRU by sampling for the same reason. See + /// . + /// + /// + /// Invalid entries first, and for free. The sample is walked anyway, so anything already dead + /// is taken on sight rather than being scored: it costs nothing to release and, unlike a live entry, + /// nobody wanted it. Only if the sample was all-live does the oldest of them go. + /// + /// + /// Runs after a store, so the budget is a target the cache returns to rather than a wall - it is + /// briefly overshot by one entry. Refusing the store instead would throw away a reply already paid + /// for in full. + /// + /// + /// The loop is bounded by rather than by "until it fits": under concurrent + /// stores it might otherwise never catch up, and evicting for ever is a worse failure than being + /// briefly over budget. + /// + /// + private int EvictToBudget() + { + if (!Options.HasBudget) return 0; + + var evicted = 0; + for (var attempts = _entries.Count; attempts > 0 && IsOverBudget(); attempts--) + { + if (!TryEvictOne()) break; + evicted++; + } + + if (evicted != 0) Interlocked.Add(ref _evicted, evicted); + return evicted; + } + + private bool IsOverBudget() + => (Options.MaxBytes is long maxBytes && Volatile.Read(ref _bytes) > maxBytes) + || (Options.MaxEntries is int maxEntries && _entries.Count > maxEntries); + + /// + /// + /// The enumerator of a is a moving target and that + /// is fine here: a sample does not need to be a snapshot, only a handful of real entries. Taking the + /// first few is a poor sample when the enumeration order is stable, which is why the starting point + /// moves - otherwise the same few entries would be offered up every time and evicted in turn, + /// regardless of age. + /// + /// + /// Two details that are easy to get subtly wrong. The start is chosen so a whole sample is + /// always available - stopping at the end of the enumeration rather than wrapping would truncate + /// samples that began near it, which quietly under-samples everything at the front of the table. + /// And the cursor advances per call rather than coming from the clock: a burst of evictions happens + /// far faster than Environment.TickCount changes, so a clock-derived start would hand out + /// the same window repeatedly within one burst. + /// + /// + private bool TryEvictOne() + { + var sampleSize = Options.EvictionSampleSize; + var count = _entries.Count; + var skip = count <= sampleSize + ? 0 + : (int)((uint)Interlocked.Increment(ref _evictCursor) % (uint)(count - sampleSize + 1)); + + EntryKey oldestKey = default; + Entry? oldest = null; + var seen = 0; + var index = 0; + + foreach (var pair in _entries) + { + if (index++ < skip) continue; + + // already dead: no scoring needed, and nobody is losing anything they wanted + if (!pair.Value.IsValid) + { + if (!_entries.TryRemove(pair.Key, out var dead)) continue; + Release(pair.Key, dead); + return true; + } + + if (oldest is null || pair.Value.FilledAt < oldest.FilledAt) + { + oldest = pair.Value; + oldestKey = pair.Key; + } + + if (++seen >= sampleSize) break; + } + + if (oldest is null) + { + // the enumeration started past the end of a table that has since shrunk; the caller's + // bounded loop will come back round if we are still over budget + return false; + } + + if (!_entries.TryRemove(oldestKey, out var removed)) return false; + Release(oldestKey, removed); + return true; + } + /// Release every cached payload and key. public void Dispose() { foreach (var pair in _entries) { if (!_entries.TryRemove(pair.Key, out var entry)) continue; - entry.Payload.Dispose(); - pair.Key.Frame.Dispose(); + Release(pair.Key, entry); } _keys.InvalidateAll(); @@ -1021,6 +1171,14 @@ private sealed class Entry(RespPayload payload, Dependency[] dependencies) internal RespPayload Payload { get; } = payload; + /// What this entry holds, for the budget; see . + /// + /// Captured once rather than read from the payload each time: the payload is released when the + /// entry goes, and the budget has to be credited by exactly what it was debited, whichever side + /// of that release the accounting happens on. + /// + internal int Bytes { get; } = payload.RetainedBytes; + /// When this entry was filled, for expiry. See . internal long FilledAt { get; } = Stopwatch.GetTimestamp(); diff --git a/src/StackExchange.Redis/Interpolated/RespPayload.cs b/src/StackExchange.Redis/Interpolated/RespPayload.cs index 7ed90feb3..a292b9f47 100644 --- a/src/StackExchange.Redis/Interpolated/RespPayload.cs +++ b/src/StackExchange.Redis/Interpolated/RespPayload.cs @@ -66,6 +66,17 @@ public static RespPayload Create(ReadOnlySpan value) /// The number of live references; zero once the blob is back in the pool. internal int RefCount => _lease.RefCount; + /// + /// The memory this payload actually holds, which is not the same as the number of bytes it carries. + /// + /// + /// A reply is copied into its own rent from , and the shared pool serves + /// from power-of-two buckets - so a 33-byte reply holds 64, and a budget counted in payload lengths + /// would under-report by up to a factor of two. That is exactly the error that lets a quota fail to + /// bind under the workload that most needs it to, so the quota counts this instead. + /// + internal int RetainedBytes => _lease.GetSpan().Length; + /// /// This reply as a that shares these bytes rather than copying them. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index faa8a249e..129b0b4a5 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -381,3 +381,11 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.CacheTrackingMode [SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.Broadcast = 0 -> StackExchange.Redis.Interpolated.CacheTrackingMode [SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.PerKey = 1 -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.get -> int +[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.get -> long? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.get -> int? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.init -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Bytes.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Evicted.get -> long diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 60cf1cf89..a4328ead6 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1226,4 +1226,181 @@ public void PerKeyTrackingCachesAnyKey() Assert.Equal(0, cache.RefusedNotTracked); } + /// Fill a cache with distinct single-key entries. + private static void Fill(RespClientCache cache, int count, string response = "$1\r\nx\r\n") + { + for (var i = 0; i < count; i++) + { + var frame = Get("key" + i); + if (cache.TryBeginFill(ref frame, 0, out var fill)) + { + Complete(cache, fill, response); + } + else + { + frame.Dispose(); + } + } + } + + /// An entry limit is honoured, by evicting rather than by refusing. + /// + /// Eviction runs after the store, so the budget is a target the cache returns to rather than a wall: + /// refusing instead would throw away a reply already paid for in full, having no idea yet whether it + /// was worth more than what is already held. + /// + [Fact] + public void AnEntryLimitEvictsDownToSize() + { + using var cache = new RespClientCache(new CacheOptions { MaxEntries = 10 }); + + Fill(cache, 50); + + Assert.Equal(10, cache.Count); + Assert.Equal(50, cache.Stored); // everything really was stored... + Assert.Equal(40, cache.Evicted); // ...and the excess evicted, not refused + } + + /// A byte budget is honoured, and counts what entries hold rather than what they carry. + [Fact] + public void AByteBudgetEvictsDownToSize() + { + // sizing matters here: a 7-byte reply is rented from the pool's 16-byte bucket, so 200 of them + // hold ~3.2KB. A budget above that would be tested by a cache that never reached it. + using var cache = new RespClientCache(new CacheOptions { MaxBytes = 512 }); + + Fill(cache, 200); + + Assert.True(cache.Bytes <= 512, $"over budget: {cache.Bytes}"); + Assert.True(cache.Count > 0, "evicted everything"); + Assert.True(cache.Evicted > 0, "nothing was evicted"); + } + + /// The byte count tracks stores, evictions, sweeps and disposal alike. + /// + /// Drift here is the failure that makes a budget useless without looking broken: a count that only ever + /// rises stops admitting anything, and one that only ever falls stops binding. Both are silent. + /// + [Fact] + public void TheByteCountReturnsToZeroWhenEverythingGoes() + { + var cache = new RespClientCache(); + try + { + Assert.Equal(0, cache.Bytes); + Fill(cache, 20); + Assert.True(cache.Bytes > 0); + + // invalidate half, and sweep them + for (var i = 0; i < 10; i++) cache.OnInvalidate(Utf8("key" + i)); + Assert.Equal(10, cache.Sweep()); + Assert.Equal(10, cache.Count); + Assert.True(cache.Bytes > 0); + } + finally + { + cache.Dispose(); + } + + Assert.Equal(0, cache.Count); + Assert.Equal(0, cache.Bytes); + } + + /// A refresh replacing an entry adjusts the budget by the difference, not by the whole. + /// + /// The replace path swaps the value in place rather than removing and re-adding, so it is the one store + /// that has to credit the old entry itself. Getting it wrong leaks budget on every refresh, which shows + /// up only after a cache has been running for a while - the worst kind of bug to go looking for. + /// + [Fact] + public async Task ARefreshAdjustsTheBudgetByTheDifference() + { + using var cache = new RespClientCache(new CacheOptions + { + DefaultPolicy = new CachePolicy + { + RefreshAfter = TimeSpan.FromMilliseconds(40), + TimeToLive = TimeSpan.FromMinutes(5), + }, + }); + var executor = new FakeExecutor("$1\r\nx\r\n"); + var context = Via(executor, cache); + + var frame = Get("abc"); + Assert.Equal("$1|x|", await context.SendAsync(ref frame, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + var first = cache.Bytes; + Assert.True(first > 0); + + await Task.Delay(90); // past the soft threshold: the next read is served AND starts a refresh + var again = Get("abc"); + Assert.Equal("$1|x|", await context.SendAsync(ref again, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + + Assert.True( + await WaitUntil(() => cache.Stored == 2), + $"the refresh never landed: stored={cache.Stored} refreshes={cache.Refreshes}"); + + Assert.Equal(1, cache.Count); + Assert.Equal(first, cache.Bytes); // same size reply: replacing must not have doubled the budget + } + + private static async Task WaitUntil(Func condition, int millis = 2000) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + while (watch.ElapsedMilliseconds < millis) + { + if (condition()) return true; + await Task.Delay(10); + } + + return condition(); + } + + /// Eviction prefers entries that are already dead, and only then the oldest of a sample. + /// + /// The sample is being walked anyway, so a dead entry is taken on sight: it costs nothing to release + /// and, unlike a live one, nobody wanted it. Anything else would evict something useful while something + /// useless sat next to it. + /// + [Fact] + public void EvictionTakesDeadEntriesBeforeLiveOnes() + { + // a sample at least as large as the table means the whole table is examined, so this asserts the + // preference rather than the luck of which window was sampled + using var cache = new RespClientCache(new CacheOptions { MaxEntries = 10, EvictionSampleSize = 64 }); + + Fill(cache, 10); + Assert.Equal(10, cache.Count); + + // key3 is invalidated but still resident - it is key0 that is OLDEST, so an age-only policy would + // take that one and leave the dead entry sitting there + cache.OnInvalidate(Utf8("key3")); + + var extra = Get("pushes-us-over"); + Assert.True(cache.TryBeginFill(ref extra, 0, out var fill)); + Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); + + Assert.Equal(10, cache.Count); + Assert.Equal(1, cache.Evicted); + + using var dead = Get("key3"); + Assert.False(cache.TryGet(dead.AsLookupKey(), 0, out _), "the dead entry should have been the one to go"); + + using var oldest = Get("key0"); + Assert.True(cache.TryGet(oldest.AsLookupKey(), 0, out var alive), "the oldest LIVE entry should have survived"); + alive.Release(); + } + + /// Budgets must be positive; null is how you say "no limit". + [Fact] + public void NonPositiveBudgetsAreRejected() + { + Assert.Throws(() => new CacheOptions { MaxBytes = 0 }); + Assert.Throws(() => new CacheOptions { MaxEntries = 0 }); + Assert.Throws(() => new CacheOptions { EvictionSampleSize = 0 }); + + // and unbounded is the default, because that is what a cache without a budget actually is + Assert.Null(CacheOptions.Default.MaxBytes); + Assert.Null(CacheOptions.Default.MaxEntries); + } + } From a416320a780b7d9322fffb1a8dd89d05b329c37a Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 23:18:29 +0100 Subject: [PATCH 136/360] Queue: get the arrays off the new API 32 array occurrences on the experimental surface, 29 of them ValueTask returns, inherited from a surface that had no alternative. The inputs were already done right as ReadOnlySpan, so this is one-sided - and it is the side that allocates per call with nothing able to reclaim it. Recorded with the reasons it should not wait: T[] can never become anything else without a binary break, so the experimental window is the only chance; and every command group added in the old shape is more to undo, while groups are being added now. Also records the one genuine exception - RespAttribute, whose arguments must be arrays because the CLR gives no choice - so nobody "fixes" it later. --- design/interpolated-resp-writer.queue.md | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index b42e645ee..de01dd0e4 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -32,6 +32,38 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Next +- [ ] **Get the arrays off the new API.** There should be very close to zero. Counted today: 32 array + occurrences on the `SER010`/`SER011` surface, of which **29 are `ValueTask` returns** - + `RedisValue[]`, `HashEntry[]`, `SortedSetEntry[]`, `double?[]`, `long[]`, `bool[]`, + `ExpireResult[]`, `PersistResult[]`. Inherited wholesale from the old surface, where there was no + alternative; here there is. + The *inputs* were already done right - `ReadOnlySpan` throughout - so this is one-sided, and it is + the side that allocates per call with nothing able to reclaim it. + + **Why now and not later.** An array return is a binary-compat trap of its own: `T[]` can never + become anything else without a break, so the experimental window is the only chance. And the cost + grows with every command group added - each new group written in the old shape is more to undo, and + groups are being added right now. + + **The shape.** A return cannot be a span, because these are all `async`; it has to be something that + carries a count and can be given back. `ReadOnlyLease` already exists for exactly this reason and + already solved the hard part (see 6.16 - `Release()` is a bare decrement, which is why it is a class + and not a struct). The value-type element arrays are the sweetest: `double?[]`, `long[]`, `bool[]`, + `ExpireResult[]`, `PersistResult[]` pool with *no* element allocation at all. For `RedisValue[]` the + lease saves the array and not the elements - `RedisValue` has no lifetime, which is settled and not + to be relitigated - but on a large `MGET` the array is the part that lands in gen-2. + + **The honest cost:** a lease must be disposed and an array need not be, so this trades forgiveness + for reclaim. That trade is already made elsewhere in this design (`RespResult`, `ReadOnlyLease`), + so the inconsistency today is that these were left behind, not that changing them is novel. + + **The one real exception:** `RespAttribute` - `params string[]` and `Tokens`. Attribute arguments + must be arrays; the CLR gives no choice. Worth stating so it is not "fixed" by someone later. + + Blocked on nothing, but it wants `Parse(ref RespReader)` (below) to land first or alongside: filling + a pooled buffer straight from the reader is the mechanism, and doing it twice would be silly. + + - [ ] **`Parse(ref RespReader)`** (§2.2, §6.16). Smaller prize than it looked once the outgoing-copy rule landed — the sharing argument moved to `ReadOnlyLease` — so it is back to being about **composability**: `IRespHandler` built from `IRespHandler`. Cheapest while handlers live in From a32a74956c2140f5545e317a20aad6ed6846288f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 14 Sep 2026 23:20:27 +0100 Subject: [PATCH 137/360] Queue: how the lease returns satisfy the old array API Records why the obvious bridge does not work, before someone builds it: an internal "hand me your buffer" hatch on ReadOnlyLease can essentially never fire, because Rent goes to ArrayPool.Shared and returns an oversized array while the old contract promises an exactly-sized one the caller owns. So the variant is not a method on the lease but a question about how the result is built - which is a question about the handler. Preferred shape is one command factored into an internal core taking IRespHandler, with the public method passing the lease handler and the transitional adapter passing the array one: command written once, legacy path allocating exactly what it always did, and no sharp edge on a type whose point is unambiguous ownership. The Adopt/TryDetachArray variant is recorded as the fallback rather than dropped. --- design/interpolated-resp-writer.queue.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index de01dd0e4..79a58d825 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -63,6 +63,36 @@ a line saying why, because "we decided not to" is worth as much as "we did". Blocked on nothing, but it wants `Parse(ref RespReader)` (below) to land first or alongside: filling a pooled buffer straight from the reader is the mechanism, and doing it twice would be silly. + **Satisfying the old API, which still says `T[]`.** `TransitionalDatabase` has to keep returning + arrays, so something has to bridge. The obvious move - give `ReadOnlyLease` an internal "hand me + your buffer" escape hatch - **does not work**, and it is worth saying why before someone tries it: + `Rent` goes to `ArrayPool.Shared`, which returns an *oversized* array, while the old contract + promises an exactly-sized one the caller owns. The steal could essentially never fire. So the variant + is not a method on the lease; it is a question about how the result is *built*, which is a question + about the handler. + + Preferred shape: **one command, two handlers.** Factor each multi-result command into an internal + core taking `IRespHandler`, and let the public method pass the lease handler while the + transitional adapter passes the array one: + + ```csharp + internal static ValueTask GetCore(in RespStrings s, ReadOnlySpan keys, CommandFlags flags, IRespHandler handler) + => s.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, handler); + ``` + + The command is still written once, the legacy path allocates exactly what it always did - no pooled + rent, no copy, no waste - and neither shape needs an escape hatch on a public type. `RespHandlers.Values` + (`IRespHandler`) already exists and is one of the three non-return array sites: it is + not deleted, it is demoted to the legacy path. + + Fallback if the per-command internal core proves tiresome: an internal `Adopt(T[] exact)` construction + mode plus `TryDetachArray`, handing the array over once and neutering the lease. Strictly internal, + the same rule as `RespResult` buffer sharing. Recorded as second choice, not first, because it puts a + sharp edge on a type whose whole point is that ownership is unambiguous. + + `ToArray()` stays public on the lease regardless - that is the escape hatch for *callers* who want an + array, and it copies, honestly and visibly. + - [ ] **`Parse(ref RespReader)`** (§2.2, §6.16). Smaller prize than it looked once the outgoing-copy rule landed — the sharing argument moved to `ReadOnlyLease` — so it is back to being about From bbf38e427b451c7ee3197e8cce63ac008c6558fa Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 04:42:08 +0100 Subject: [PATCH 138/360] Queue: the legacy array bridge is a parallel internal extension method Correcting the shape recorded yesterday. Not a generic core taking a handler, and certainly not an escape hatch on the lease: a sibling extension method on the typed context, internal, sharing the message construction and differing only in the handler. Three things that buys. It keeps the classic `this` extension form, so the legacy sibling retires the way everything else on this surface does. Being internal it never reaches the public API, so it adds no array site to fix later. And TransitionalDatabase stays a genuine one-line pass-through, which was the point of that class. Sharing the construction wants the Execute -> Render rename first: Render is exactly the primitive both siblings need, since each call wants its own frame and what is shared is the composition rather than the frame. --- design/interpolated-resp-writer.queue.md | 40 +++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 79a58d825..5c47d4b4d 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -71,24 +71,36 @@ a line saying why, because "we decided not to" is worth as much as "we did". is not a method on the lease; it is a question about how the result is *built*, which is a question about the handler. - Preferred shape: **one command, two handlers.** Factor each multi-result command into an internal - core taking `IRespHandler`, and let the public method pass the lease handler while the - transitional adapter passes the array one: + Shape: **a parallel internal extension method on the typed context**, sitting beside the public one, + sharing the message construction and differing only in the handler: ```csharp - internal static ValueTask GetCore(in RespStrings s, ReadOnlySpan keys, CommandFlags flags, IRespHandler handler) - => s.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, handler); - ``` + public static ValueTask> Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, RespHandlers.ValueLease); - The command is still written once, the legacy path allocates exactly what it always did - no pooled - rent, no copy, no waste - and neither shape needs an escape hatch on a public type. `RespHandlers.Values` - (`IRespHandler`) already exists and is one of the three non-return array sites: it is - not deleted, it is demoted to the legacy path. + internal static ValueTask GetArray(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + => strings.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, RespHandlers.Values); + ``` - Fallback if the per-command internal core proves tiresome: an internal `Adopt(T[] exact)` construction - mode plus `TryDetachArray`, handing the array over once and neutering the lease. Strictly internal, - the same rule as `RespResult` buffer sharing. Recorded as second choice, not first, because it puts a - sharp edge on a type whose whole point is that ownership is unambiguous. + Three things this buys over a generic `GetCore(..., IRespHandler)` helper. It stays in the + classic `this` extension form, so the legacy sibling retires the way everything else on this surface + does. Being **internal**, it never appears on the public API, so it adds no array site to fix later. + And `TransitionalDatabase` stays a genuine one-line pass-through - `context.Strings.GetArray(...)` - + which was the whole point of that class. + + Sharing the construction is worth doing once `Execute` is renamed to `Render` (top of this list): + `Render($"...")` is exactly the primitive both siblings need, since each call wants its own frame and + what is shared is the *composition*, not the frame. Until then the interpolated line is duplicated, + which is one line and no knowledge. + + `RespHandlers.Values` (`IRespHandler`) already exists and is one of the three non-return + array sites: it is not deleted, it is demoted - off the public surface, onto the legacy sibling. + + Rejected, and recorded so nobody builds it: an internal "hand me your buffer" hatch on + `ReadOnlyLease`. `Rent` goes to `ArrayPool.Shared`, which returns an *oversized* array, while + the old contract promises an exactly-sized one the caller owns - so the steal could essentially never + fire, and what is left is `ToArray()` wearing a disguise. It would also put an ownership ambiguity + into the one type whose entire point is that ownership is unambiguous. `ToArray()` stays public on the lease regardless - that is the escape hatch for *callers* who want an array, and it copies, honestly and visibly. From 682cc687cf304ac43ee9f3f2e6bb27a65318ffa2 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 04:52:28 +0100 Subject: [PATCH 139/360] Rendering is not executing: RespContext.Execute -> Render RespContext.Execute returned a rendered frame and dispatched nothing, while IDatabase.Execute in this same library sends a command and returns its result. Two opposite meanings for one verb, in one codebase, is a trap for every reader after the first - and it kept the ad-hoc surface on ExecuteAsync only, because async had no clash. 134 call sites, all in tests and benchmarks: src had none outside the context itself. Renamed by letting the compiler name the lines rather than by pattern, because ".Execute(" also matches IDatabase/IServer.Execute, which must not move. Also serialises RespCacheInvalidationTests against RespTrackingTests. A FLUSHDB sends an UNFILTERABLE flush push to every tracking client on the server, so the flush test was evicting entries out from under the other class's assertions - the design working exactly as documented in 6.13, and a test interfering with another test. Both are now in NonParallelCollection. --- design/interpolated-resp-writer.md | 20 ++--- design/interpolated-resp-writer.queue.md | 17 ++-- .../Interpolated/RespAppend.cs | 2 +- .../Interpolated/RespContext.cs | 22 ++--- .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- .../ClientCacheBenchmarks.cs | 4 +- .../InterpolatedWriterBenchmarks.cs | 10 +-- .../InterpolatedAppendTests.cs | 10 +-- .../InterpolatedCustomArgTests.cs | 16 ++-- .../InterpolatedLiteralCommandTests.cs | 28 +++---- .../InterpolatedWriterCacheKeyTests.cs | 14 ++-- .../InterpolatedWriterDemo.cs | 18 ++-- .../InterpolatedWriterFragmentTests.cs | 28 +++---- .../InterpolatedWriterUnitTests.cs | 84 +++++++++---------- .../InterpolatedWriterUsingStaticTests.cs | 8 +- .../MessageToRespFrameTests.cs | 6 +- .../RespCacheInvalidationTests.cs | 4 + .../RespClientCacheTests.cs | 40 ++++----- .../RespCommandTests.cs | 18 ++-- .../RespEndToEndTests.cs | 2 +- .../RespSurfaceStringsTests.cs | 2 +- .../RespTrackingTests.cs | 3 +- 22 files changed, 180 insertions(+), 180 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 5df962ce3..d2c9c798d 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -9,7 +9,7 @@ there is no public API commitment yet. The idea: let command construction read as ```csharp -Execute($"{cmd}{key}{value}", handler); +Render($"{cmd}{key}{value}", handler); ``` where `$"..."` binds to a custom interpolated string handler that writes RESP directly, rather than @@ -138,8 +138,8 @@ Every part of the command must be a hole, with one exception: a **single space** ```csharp ctx.Execute(RedisCommand.SET, $"{key} {value}") // ok - the space is discarded -ctx.Execute($"SET {key} {value}") // rejected - "SET " is not a separator -ctx.Execute($"{cmd} {key}") // rejected - two spaces +ctx.Render($"SET {key} {value}") // rejected - "SET " is not a separator +ctx.Render($"{cmd} {key}") // rejected - two spaces ``` The space earns its place on readability: `$"{RedisCommand.SET} {key} {value}"` mirrors how the command @@ -604,7 +604,7 @@ That is a problem, because `CommandMap` is not reachable from there — it is no assembly but **breaks every `IDatabase` mock**, and breaks it during command *construction*, in the caller's frame, before the mock's `Execute` is reached. -**Resolution: a dedicated context type as the receiver** — `ctx.Execute($"...")` — carrying: +**Resolution: a dedicated context type as the receiver** — `ctx.Render($"...")` — carrying: | Shared per multiplexer | Varies per instance | | --- | --- | @@ -747,13 +747,13 @@ constant. **Gotcha:** `using var` cannot be passed by `ref` (CS1657). Mark resolution members `readonly` so `in` works, or callers are forced into `try`/`finally`. -### 4.1 `Compose` / `Execute(ref cmd)` — the shape for optional arguments +### 4.1 `Compose` / `Render(ref cmd)` — the shape for optional arguments > **`cmd.Append($"…")`.** A conditional fragment is now written the same way as the command itself: > ```csharp > var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); > if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); -> using var frame = ctx.Execute(ref cmd); +> using var frame = ctx.Render(ref cmd); > ``` > rather than a sequence of `AppendFormatted` calls whose order is the caller's to keep straight. > @@ -831,7 +831,7 @@ Implemented in the spike (§9): ```csharp var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); if (withTtl) { cmd.AppendFormatted(ex); cmd.AppendFormatted(ttl); } -using var frame = ctx.Execute(ref cmd); +using var frame = ctx.Render(ref cmd); ``` `Compose` carries `[InterpolatedStringHandlerArgument("")]` and simply returns the handler. @@ -850,11 +850,11 @@ to interpolate: ```csharp var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); foreach (var key in keys) cmd.AppendFormatted(key); -using var frame = ctx.Execute(ref cmd); +using var frame = ctx.Render(ref cmd); ``` `argHint` only sizes the initial rent; it is not a promise, and appending more simply grows the buffer. -**`Execute(ref cmd)` needs no new overload** — and could not have one, since the attribute does not change +**`Render(ref cmd)` needs no new overload** — and could not have one, since the attribute does not change the signature: it binds to the same `Execute`, because the interpolated-string-handler conversion applies only when the argument *is* an interpolated string. Passing a real variable by `ref` is an ordinary argument and the attribute is ignored. Verified. @@ -1455,7 +1455,7 @@ category without it. Pinned by tests, since the failure is silent. None of the three is visible at the call site, which reduces to: ```csharp -var req = ctx.Execute($"{RedisCommand.GET}{key}"); +var req = ctx.Render($"{RedisCommand.GET}{key}"); return executor.Send(ref req, handler, cache); // no using, nothing to release, nothing to order ``` diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 5c47d4b4d..65d0150ed 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -11,13 +11,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Now -- [ ] **Free up the name `Execute`.** `RespContext.Execute(...)` currently returns a rendered `RespFrame` — - it does not execute anything — while `IDatabase.Execute` in this same library *sends and returns a - result*. Two opposite meanings for one verb, in one codebase. Rename the frame-returning one - (`Render` reads right) and let `Execute` mean what everybody expects. ~73 call sites, entirely - mechanical, but it will collide with any in-flight worktree, so do it immediately after a merge. - Until then `ExecuteAsync` carries the ad-hoc API, because async has no clash. - - [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and @@ -88,10 +81,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". And `TransitionalDatabase` stays a genuine one-line pass-through - `context.Strings.GetArray(...)` - which was the whole point of that class. - Sharing the construction is worth doing once `Execute` is renamed to `Render` (top of this list): - `Render($"...")` is exactly the primitive both siblings need, since each call wants its own frame and - what is shared is the *composition*, not the frame. Until then the interpolated line is duplicated, - which is one line and no knowledge. + Sharing the construction is available now that `Render` exists: it is exactly the primitive both + siblings need, since each call wants its own frame and what is shared is the *composition*, not the + frame. Duplicating the interpolated line instead is one line and no knowledge, so either is fine. `RespHandlers.Values` (`IRespHandler`) already exists and is one of the three non-return array sites: it is not deleted, it is demoted - off the public surface, onto the legacy sibling. @@ -186,8 +178,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` - [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` - [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` +- [x] `Execute` -> `Render` on the context: rendering is not executing — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` -- [x] Byte and entry quotas, with sampled eviction — this change +- [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and `Sweep` reclaiming expired entries rather than only invalidated ones — `e2d2ea3c` diff --git a/src/StackExchange.Redis/Interpolated/RespAppend.cs b/src/StackExchange.Redis/Interpolated/RespAppend.cs index 53d6608f8..a9d6f4bd2 100644 --- a/src/StackExchange.Redis/Interpolated/RespAppend.cs +++ b/src/StackExchange.Redis/Interpolated/RespAppend.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis.Interpolated /// /// var cmd = ctx.Compose($"{RedisCommand.SET}{key}{value}"); /// if (withTtl) cmd.Append($"{RespLiterals.EX}{ttl}"); - /// using var frame = ctx.Execute(ref cmd); + /// using var frame = ctx.Render(ref cmd); /// /// [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] diff --git a/src/StackExchange.Redis/Interpolated/RespContext.cs b/src/StackExchange.Redis/Interpolated/RespContext.cs index 9b4f91839..ad75d6575 100644 --- a/src/StackExchange.Redis/Interpolated/RespContext.cs +++ b/src/StackExchange.Redis/Interpolated/RespContext.cs @@ -348,7 +348,7 @@ internal long MaxCacheAgeTicks /// /// var cmd = ctx.Compose($"{RedisCommand.SET} {key} {value}"); /// if (withTtl) { cmd.AppendFormatted(RespLiterals.EX); cmd.AppendFormatted(ttl); } - /// using var frame = ctx.Execute(ref cmd); + /// using var frame = ctx.Render(ref cmd); /// /// /// @@ -388,7 +388,7 @@ internal RespCommandHandler Compose( /// /// var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); /// foreach (var key in keys) cmd.AppendFormatted(key); - /// using var frame = ctx.Execute(ref cmd); + /// using var frame = ctx.Render(ref cmd); /// /// /// The command to issue. @@ -411,18 +411,18 @@ public RespCommandHandler Compose( /// As the RedisCommand overload, taking a command name. /// The command name to issue. /// The interpolated arguments. - public RespFrame Execute( + public RespFrame Render( string command, [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) - => Execute(ref handler); + => Render(ref handler); /// - /// As , with the command as a real argument. + /// As , with the command as a real argument. /// - internal RespFrame Execute( + internal RespFrame Render( RedisCommand command, [InterpolatedStringHandlerArgument("", nameof(command))] ref RespCommandHandler handler) - => Execute(ref handler); + => Render(ref handler); /// /// Render a command. The "" argument passes THIS CONTEXT - the receiver of the call - into @@ -430,12 +430,14 @@ internal RespFrame Execute( /// server type. /// /// - /// A real Execute would go on to dispatch the frame; this spike stops at "the right bytes were - /// rendered, and we know which arguments were keys". + /// Renders; it does not send. The name matters because IDatabase.Execute in this same + /// library sends a command and returns its result - two opposite meanings for one verb would be a + /// trap for every reader after the first. Dispatch is Send/SendAsync; this stops at + /// "the right bytes were rendered, and we know which arguments were keys". /// /// The interpolated command and arguments. /// The rendered frame, with routing and key metadata. - public RespFrame Execute([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) + public RespFrame Render([InterpolatedStringHandlerArgument("")] ref RespCommandHandler handler) { if (CancellationToken.IsCancellationRequested) { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 129b0b4a5..f6c9ff181 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -82,8 +82,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespContext.Compose(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespContext.Compose(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespContext.Database.get -> int -[SER010]StackExchange.Redis.Interpolated.RespContext.Execute(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame -[SER010]StackExchange.Redis.Interpolated.RespContext.Execute(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespContext.Render(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame +[SER010]StackExchange.Redis.Interpolated.RespContext.Render(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void [SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType diff --git a/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs index 167638cc0..872fd5476 100644 --- a/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/ClientCacheBenchmarks.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; @@ -26,7 +26,7 @@ public void Setup() var ctx = new RespContext(); for (var i = 0; i < CachedKeys; i++) { - var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)("key:" + i)}"); + var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)("key:" + i)}"); if (_cache.TryBeginFill(ref frame, 0, out var fill)) { var payload = RespPayload.Create(Encoding.UTF8.GetBytes("$5\r\nhello\r\n")); diff --git a/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs index deaacba63..05b416df0 100644 --- a/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/InterpolatedWriterBenchmarks.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Collections.Generic; using BenchmarkDotNet.Attributes; @@ -61,7 +61,7 @@ public int KeyValue_Adhoc() [BenchmarkCategory("KeyValue"), Benchmark] public int KeyValue_Interpolated() { - using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value}"); + using var frame = _ctx.Render(RedisCommand.SET, $"{_key} {_value}"); _target.Reset(); _target.Write(frame.Span); return _target.Written; @@ -81,7 +81,7 @@ public int Expiry_Message() [BenchmarkCategory("Expiry"), Benchmark] public int Expiry_Interpolated() { - using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); + using var frame = _ctx.Render(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); _target.Reset(); _target.Write(frame.Span); return _target.Written; @@ -94,14 +94,14 @@ public int Expiry_Interpolated() [BenchmarkCategory("Separators"), Benchmark(Baseline = true)] public int Separators_None() { - using var frame = _ctx.Execute(RedisCommand.SET, $"{_key}{_value}{(RedisValue)"EX"}{(RedisValue)300}"); + using var frame = _ctx.Render(RedisCommand.SET, $"{_key}{_value}{(RedisValue)"EX"}{(RedisValue)300}"); return frame.ArgCount; } [BenchmarkCategory("Separators"), Benchmark] public int Separators_Spaced() { - using var frame = _ctx.Execute(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); + using var frame = _ctx.Render(RedisCommand.SET, $"{_key} {_value} {(RedisValue)"EX"} {(RedisValue)300}"); return frame.ArgCount; } diff --git a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs index 9da0fd193..d6b1530f8 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedAppendTests.cs @@ -49,7 +49,7 @@ public void ConditionalAppendMatchesTheUnconditionalForm(bool withTtl, string ex var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); if (withTtl) cmd.Append($"{RespLiterals.EX}{(RedisValue)300}"); - using var frame = Ctx.Execute(ref cmd); + using var frame = Ctx.Render(ref cmd); Assert.Equal(expected, Text(frame)); } @@ -63,7 +63,7 @@ public void AppendSurvivesABufferGrowth() var cmd = Ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}"); cmd.Append($"{(RedisValue)big}{(RedisValue)big}"); - using var frame = Ctx.Execute(ref cmd); + using var frame = Ctx.Render(ref cmd); var text = Text(frame); Assert.StartsWith("*4|$3|SET|$1|k|$4096|", text); Assert.Equal(4, frame.ArgCount); @@ -90,7 +90,7 @@ public void TheSourceIsEmptiedForTheDurationOfTheAppend() // ...and the other end: the handler has been emptied in turn Assert.True(CompleteThrows(ref handler), "the moved-from handler should be empty"); - using var frame = Ctx.Execute(ref cmd); + using var frame = Ctx.Render(ref cmd); Assert.Equal("*3|$3|SET|$1|k|$1|v|", Text(frame)); } @@ -115,7 +115,7 @@ public void SeveralAppendsAccumulate() cmd.Append($"{RespLiterals.EX}{(RedisValue)300}"); cmd.Append($"{(RedisValue)"XX"}"); - using var frame = Ctx.Execute(ref cmd); + using var frame = Ctx.Render(ref cmd); Assert.Equal("*6|$3|SET|$1|k|$1|v|$2|EX|$3|300|$2|XX|", Text(frame)); } @@ -125,7 +125,7 @@ public void KeysAppendedThisWayAreStillMarked() var cmd = Ctx.Compose($"{RedisCommand.MGET}{(RedisKey)"a"}"); cmd.Append($"{(RedisKey)"b"}"); - using var frame = Ctx.Execute(ref cmd); + using var frame = Ctx.Render(ref cmd); Assert.Equal(2, frame.KeyCount); var ranges = new KeyRange[2]; Assert.Equal(2, frame.TryGetKeys(ranges)); diff --git a/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs index 50b2fe110..ac52e5f26 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedCustomArgTests.cs @@ -68,7 +68,7 @@ public void WriteTo(scoped ref RespCommandHandler handler, string? format) [Fact] public void AFormatSpecifierReachesTheImplementerVerbatim() { - using var frame = Ctx.Execute($"{RedisCommand.GEOSEARCH}{(RedisKey)"k"}{new Radius(5):km}"); + using var frame = Ctx.Render($"{RedisCommand.GEOSEARCH}{(RedisKey)"k"}{new Radius(5):km}"); Assert.Equal("*4|$9|GEOSEARCH|$1|k|$1|5|$2|km|", Text(frame)); } @@ -77,10 +77,10 @@ public void EachSpellingBindsToItsOwnArity() { // a type implementing both is unambiguous: the overloads differ in arity, so the presence or // absence of the `:` in the hole decides, not overload betterness - using var plain = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Either()}"); + using var plain = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}{new Either()}"); Assert.Equal("*3|$3|GET|$1|k|$5|PLAIN|", Text(plain)); - using var formatted = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Either():xyz}"); + using var formatted = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}{new Either():xyz}"); Assert.Equal("*3|$3|GET|$1|k|$10|FORMAT:xyz|", Text(formatted)); } @@ -103,7 +103,7 @@ public void ThereIsNoAlignmentOverloadAndThereShouldNeverBe() [Fact] public void ACustomTypeCanAppearInAHole() { - using var frame = Ctx.Execute($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); + using var frame = Ctx.Render($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); Assert.Equal("*4|$6|ZRANGE|$1|k|$1|0|$1|9|", Text(frame)); Assert.Equal(4, frame.ArgCount); } @@ -111,7 +111,7 @@ public void ACustomTypeCanAppearInAHole() [Fact] public void WritingNothingContributesNoArgument() { - using var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Absent()}"); + using var frame = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}{new Absent()}"); Assert.Equal("*2|$3|GET|$1|k|", Text(frame)); Assert.Equal(2, frame.ArgCount); } @@ -126,7 +126,7 @@ static long Measure() var before = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < 64; i++) { - using var frame = Ctx.Execute($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); + using var frame = Ctx.Render($"{RedisCommand.ZRANGE}{(RedisKey)"k"}{new Window(0, 9)}"); } return GC.GetAllocatedBytesForCurrentThread() - before; } @@ -142,7 +142,7 @@ public void OptingInBeatsAnIncidentalConversion() // generic is an exact match by inference and wins. That is the WANTED answer here - implementing // the interface is a deliberate statement about how the type should be written - but it is the // same mechanism the design notes warn about for an unconstrained generic, so it is pinned. - using var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}{new Ambiguous()}"); + using var frame = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}{new Ambiguous()}"); Assert.Equal("*3|$3|GET|$1|k|$9|INTERFACE|", Text(frame)); } @@ -151,7 +151,7 @@ public void AKeyAfterACustomArgumentIsStillMarkedCorrectly() { // the implementer writes through the handler's own counters, so it cannot misreport how many // arguments it wrote - which is what would otherwise shift every key mark after it - using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{new Window(0, 9)}{(RedisKey)"b"}"); + using var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{new Window(0, 9)}{(RedisKey)"b"}"); Assert.Equal(5, frame.ArgCount); Assert.Equal(2, frame.KeyCount); diff --git a/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs index c8cc4becd..4457117df 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedLiteralCommandTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using StackExchange.Redis.Interpolated; @@ -23,8 +23,8 @@ private static string Text(in RespFrame frame) => public void ALeadingLiteralIsTheCommand() { var ctx = new RespContext(); - using var literal = ctx.Execute($"SET {(RedisKey)"mykey"} {(RedisValue)"marc"}"); - using var holes = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"marc"}"); + using var literal = ctx.Render($"SET {(RedisKey)"mykey"} {(RedisValue)"marc"}"); + using var holes = ctx.Render($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"marc"}"); // the whole point: the readable spelling must produce the identical frame, or it is not an // alternative spelling, it is a second implementation @@ -36,7 +36,7 @@ public void ALeadingLiteralIsTheCommand() public void TheLeadingCommandStillGoesThroughTheCommandMap() { var renamed = CommandMap.Create(new Dictionary { ["SET"] = "STORE" }); - using var frame = new RespContext(renamed).Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + using var frame = new RespContext(renamed).Render($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); Assert.Equal("*3|$5|STORE|$1|k|$1|v|", Text(frame)); } @@ -47,7 +47,7 @@ public void ADisabledLeadingCommandThrows() var ctx = new RespContext(disabled); Assert.Throws(() => { - using var frame = ctx.Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + using var frame = ctx.Render($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); }); } @@ -56,7 +56,7 @@ public void SplittingOnWhitespaceGetsContainerCommandsRight() { // CONFIG is the command and IS mapped; GET is an ordinary argument and is NOT - which is exactly // how CommandMap works, since it maps container verbs only - using var frame = new RespContext().Execute($"CONFIG GET {(RedisValue)"maxmemory"}"); + using var frame = new RespContext().Render($"CONFIG GET {(RedisValue)"maxmemory"}"); Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Text(frame)); Assert.Equal(3, frame.ArgCount); } @@ -64,7 +64,7 @@ public void SplittingOnWhitespaceGetsContainerCommandsRight() [Fact] public void LiteralsAfterTheCommandAreOrdinaryArguments() { - using var frame = new RespContext().Execute( + using var frame = new RespContext().Render( $"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"} EX {(RedisValue)300}"); Assert.Equal("*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|", Text(frame)); } @@ -73,8 +73,8 @@ public void LiteralsAfterTheCommandAreOrdinaryArguments() public void WhitespaceOnlyLiteralsStillContributeNothing() { var ctx = new RespContext(); - using var spaced = ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}"); - using var tight = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + using var spaced = ctx.Render($"{RedisCommand.GET} {(RedisKey)"k"}"); + using var tight = ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}"); Assert.Equal(Text(tight), Text(spaced)); Assert.Equal(2, spaced.ArgCount); } @@ -82,14 +82,14 @@ public void WhitespaceOnlyLiteralsStillContributeNothing() [Fact] public void RunsOfWhitespaceCollapse() { - using var frame = new RespContext().Execute($"CONFIG GET {(RedisValue)"maxmemory"}"); + using var frame = new RespContext().Render($"CONFIG GET {(RedisValue)"maxmemory"}"); Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Text(frame)); } [Fact] public void AnUnknownLeadingCommandIsFramedVerbatim() { - using var frame = new RespContext().Execute($"FT.SEARCH {(RedisValue)"idx"}"); + using var frame = new RespContext().Render($"FT.SEARCH {(RedisValue)"idx"}"); Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); } @@ -98,7 +98,7 @@ public void TheCommandInfoShapeWorks() { // the motivating example: a command name as an argument, alongside a literal subcommand var renamed = CommandMap.Create(new Dictionary { ["HGET"] = "HASHGET" }); - using var frame = new RespContext(renamed).Execute($"COMMAND INFO {"HGET".Command()}"); + using var frame = new RespContext(renamed).Render($"COMMAND INFO {"HGET".Command()}"); // the argument must be the MAPPED name - the server knows a renamed command only by that Assert.Equal("*3|$7|COMMAND|$4|INFO|$7|HASHGET|", Text(frame)); @@ -107,14 +107,14 @@ public void TheCommandInfoShapeWorks() [Fact] public void NonAsciiLiteralsEncodeCorrectly() { - using var frame = new RespContext().Execute($"ECHO héllo{(RedisValue)"!"}"); + using var frame = new RespContext().Render($"ECHO héllo{(RedisValue)"!"}"); Assert.Equal("*3|$4|ECHO|$6|héllo|$1|!|", Text(frame)); } [Fact] public void KeysAreStillOnlyMarkedFromKeyHoles() { - using var frame = new RespContext().Execute($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); + using var frame = new RespContext().Render($"SET {(RedisKey)"k"} {(RedisValue)"v"}"); // a literal token is never a key: it cannot be, since key-ness is what the hole type says Assert.Equal(1, frame.KeyCount); diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs index c5a3c718a..92986c9b6 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterCacheKeyTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Text; using System.Threading; @@ -17,7 +17,7 @@ public class InterpolatedWriterCacheKeyTests private static RespRequest Key(string key) { var ctx = new RespContext(); - var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)key}"); return frame.Detach(); // ownership moves to the key; the frame must not be disposed after this } @@ -100,7 +100,7 @@ static void Probe(ConcurrentDictionary cache) { // the HIT path borrows rather than detaching: Detach allocates a RefCountedBuffer per call var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}"); if (cache.TryGetValue(frame.AsLookupKey(), out var found) && found.TryRetain()) { try @@ -119,7 +119,7 @@ static void Probe(ConcurrentDictionary cache) public void ABorrowedKeyCannotBeRetainedAndSoCannotBeStored() { var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}"); var borrowed = frame.AsLookupKey(); Assert.False(borrowed.IsOwned); @@ -128,7 +128,7 @@ public void ABorrowedKeyCannotBeRetainedAndSoCannotBeStored() // key refuses to retain - so a pooled array cannot reach a cache by following the idiom Assert.False(borrowed.TryRetain(out _)); - using var owned = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach(); + using var owned = ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach(); Assert.True(owned.IsOwned); Assert.Equal(borrowed, owned); // same bytes either way } @@ -141,14 +141,14 @@ public void DetachAllocatesAndBorrowingDoesNot() var borrowed = AllocationAssert.Measure( () => { - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}"); frame.AsLookupKey(); }, iterations: 100, warmup: 200); var detached = AllocationAssert.Measure( - () => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(), + () => ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}").Detach().Dispose(), iterations: 100, warmup: 200); diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs index c11a448e9..9297c1bf0 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterDemo.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using StackExchange.Redis.Interpolated; using Xunit; @@ -33,7 +33,7 @@ private static string Keys(in RespFrame frame) [Fact] public void FixedArity() { - using var frame = Cluster.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + using var frame = Cluster.Render(RedisCommand.GET, $"{(RedisKey)"user:1"}"); Assert.Equal("*2|$3|GET|$6|user:1|", Frame(frame)); Assert.Equal("user:1", Keys(frame)); @@ -43,7 +43,7 @@ public void FixedArity() [Fact] public void KeyAndValue() { - using var frame = Cluster.Execute(RedisCommand.SET, $"{(RedisKey)"user:1"} {(RedisValue)"marc"}"); + using var frame = Cluster.Render(RedisCommand.SET, $"{(RedisKey)"user:1"} {(RedisValue)"marc"}"); Assert.Equal("*3|$3|SET|$6|user:1|$4|marc|", Frame(frame)); Assert.Equal("user:1", Keys(frame)); // the value is not a key, and is not marked as one @@ -53,13 +53,13 @@ public void KeyAndValue() public void KeyspaceIsolation() { var tenant = Cluster.WithKeyPrefix("t7:"); - using var frame = tenant.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + using var frame = tenant.Render(RedisCommand.GET, $"{(RedisKey)"user:1"}"); Assert.Equal("*2|$3|GET|$9|t7:user:1|", Frame(frame)); Assert.Equal("t7:user:1", Keys(frame)); // the slot follows the PREFIXED key, so tenants do not collide on a slot either - using var plain = Cluster.Execute(RedisCommand.GET, $"{(RedisKey)"user:1"}"); + using var plain = Cluster.Render(RedisCommand.GET, $"{(RedisKey)"user:1"}"); Assert.NotEqual(plain.Slot, frame.Slot); } @@ -69,7 +69,7 @@ public void OptionalArguments() var cmd = Cluster.Compose(RedisCommand.SET, $"{(RedisKey)"user:1"} {(RedisValue)"marc"}"); cmd.AppendFormatted((RedisValue)"EX"); cmd.AppendFormatted((RedisValue)300); - using var frame = Cluster.Execute(ref cmd); + using var frame = Cluster.Render(ref cmd); Assert.Equal("*5|$3|SET|$6|user:1|$4|marc|$2|EX|$3|300|", Frame(frame)); Assert.Equal("user:1", Keys(frame)); @@ -81,7 +81,7 @@ public void VariadicWithSharedHashTag() var keys = new RedisKey[] { "{u}:a", "{u}:b", "{u}:c" }; var cmd = Cluster.Compose(RedisCommand.DEL, keys.Length); foreach (var key in keys) cmd.AppendFormatted(key); - using var frame = Cluster.Execute(ref cmd); + using var frame = Cluster.Render(ref cmd); Assert.Equal("*4|$3|DEL|$5|{u}:a|$5|{u}:b|$5|{u}:c|", Frame(frame)); // beyond two keys the inline offsets give out, but the argument-index bitmap still resolves them @@ -96,7 +96,7 @@ public void CrossSlotIsDetected() var cmd = Cluster.Compose(RedisCommand.DEL, 2); cmd.AppendFormatted((RedisKey)"alpha"); cmd.AppendFormatted((RedisKey)"beta"); - using var frame = Cluster.Execute(ref cmd); + using var frame = Cluster.Render(ref cmd); Assert.Equal("*3|$3|DEL|$5|alpha|$4|beta|", Frame(frame)); Assert.Equal(ServerSelectionStrategy.MultipleSlots, frame.Slot); @@ -107,7 +107,7 @@ public void ChannelPrefix() { var pub = Cluster.WithChannelPrefix(new RedisChannel("app:", RedisChannel.PatternMode.Literal)); var channel = new RedisChannel("news", RedisChannel.PatternMode.Literal); - using var frame = pub.Execute(RedisCommand.PUBLISH, $"{channel} {(RedisValue)"hi"}"); + using var frame = pub.Render(RedisCommand.PUBLISH, $"{channel} {(RedisValue)"hi"}"); Assert.Equal("*3|$7|PUBLISH|$8|app:news|$2|hi|", Frame(frame)); Assert.Equal("", Keys(frame)); // a channel is not a key diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs index da6342398..15f130646 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterFragmentTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using StackExchange.Redis.Interpolated; @@ -51,7 +51,7 @@ internal static partial class RespLiterals public void SingleTokenFragment() { var ctx = new RespContext(); - using var frame = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX} {(RedisValue)300}"); + using var frame = ctx.Render(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX} {(RedisValue)300}"); Assert.Equal("*5|$3|SET|$1|k|$1|v|$2|EX|$3|300|", Frame(frame)); Assert.Equal(5, frame.ArgCount); @@ -62,7 +62,7 @@ public void TwoTokenFragmentCountsAsTwoArguments() { // CLIENT SETINFO LIB-NAME StackExchange.Redis var ctx = new RespContext(); - using var frame = ctx.Execute(RedisCommand.CLIENT, $"{RespLiterals.SetInfoLibName} {(RedisValue)"StackExchange.Redis"}"); + using var frame = ctx.Render(RedisCommand.CLIENT, $"{RespLiterals.SetInfoLibName} {(RedisValue)"StackExchange.Redis"}"); Assert.Equal("*4|$6|CLIENT|$7|SETINFO|$8|lib-name|$19|StackExchange.Redis|", Frame(frame)); @@ -79,7 +79,7 @@ public void MultiTokenFragmentDoesNotShiftKeyMarks() cmd.AppendFormatted(RespLiterals.MaxLenApprox); cmd.AppendFormatted((RedisValue)1000); cmd.AppendFormatted((RedisValue)"*"); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal("*6|$4|XADD|$8|stream:1|$6|MAXLEN|$1|~|$4|1000|$1|*|", Frame(frame)); Assert.Equal(6, frame.ArgCount); @@ -96,7 +96,7 @@ public void TwoKeysThenATwoTokenFragment() { // LMOVE source destination LEFT RIGHT - a real command ending in a fixed two-token pair var ctx = new RespContext(); - using var frame = ctx.Execute(RedisCommand.LMOVE, $"{(RedisKey)"src"} {(RedisKey)"dst"} {RespLiterals.LeftRight}"); + using var frame = ctx.Render(RedisCommand.LMOVE, $"{(RedisKey)"src"} {(RedisKey)"dst"} {RespLiterals.LeftRight}"); Assert.Equal("*5|$5|LMOVE|$3|src|$3|dst|$4|LEFT|$5|RIGHT|", Frame(frame)); Assert.Equal(5, frame.ArgCount); @@ -116,7 +116,7 @@ public void KeyAfterAMultiTokenFragmentIsStillTracked() var cmd = ctx.Compose(RedisCommand.SMOVE, $"{(RedisKey)"src"}"); cmd.AppendFormatted(RespLiterals.MaxLenApprox); cmd.AppendFormatted((RedisKey)"dst"); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(5, frame.ArgCount); Span ranges = stackalloc KeyRange[2]; @@ -129,7 +129,7 @@ public void KeyAfterAMultiTokenFragmentIsStillTracked() public void ConfigGetReadsAsTheCommandDoes() { var ctx = new RespContext(); - using var frame = ctx.Execute(RedisCommand.CONFIG, $"{RespLiterals.ConfigGet} {(RedisValue)"maxmemory"}"); + using var frame = ctx.Render(RedisCommand.CONFIG, $"{RespLiterals.ConfigGet} {(RedisValue)"maxmemory"}"); Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Frame(frame)); } @@ -142,7 +142,7 @@ public void StringCommandIsSpeculativelyParsedAndAliased() // a recognised name goes through the command map, so renames still apply var map = CommandMap.Create(new Dictionary { ["get"] = "xget" }); var ctx = new RespContext(map); - using var frame = ctx.Execute("get", $"{(RedisKey)"k"}"); + using var frame = ctx.Render("get", $"{(RedisKey)"k"}"); Assert.Equal("*2|$4|XGET|$1|k|", Frame(frame)); } @@ -151,8 +151,8 @@ public void StringCommandIsSpeculativelyParsedAndAliased() public void StringCommandIsCaseInsensitive() { var ctx = new RespContext(); - using var upper = ctx.Execute("GET", $"{(RedisKey)"k"}"); - using var lower = ctx.Execute("get", $"{(RedisKey)"k"}"); + using var upper = ctx.Render("GET", $"{(RedisKey)"k"}"); + using var lower = ctx.Render("get", $"{(RedisKey)"k"}"); Assert.Equal("*2|$3|GET|$1|k|", Frame(upper)); Assert.True(upper.Span.SequenceEqual(lower.Span)); @@ -164,7 +164,7 @@ public void StringCommandRespectsDisabledCommands() var map = CommandMap.Create(new Dictionary { ["get"] = null }); var ctx = new RespContext(map); - Assert.Throws(() => ctx.Execute("get", $"{(RedisKey)"k"}").Dispose()); + Assert.Throws(() => ctx.Render("get", $"{(RedisKey)"k"}").Dispose()); } [Fact] @@ -173,7 +173,7 @@ public void UnrecognisedStringCommandIsFramedVerbatim() // not a known command: no aliasing to apply, so the name goes out as written - matching // IDatabase.Execute(string, ...) behaviour for ad-hoc commands var ctx = new RespContext(); - using var frame = ctx.Execute("FT.SEARCH", $"{(RedisValue)"idx"}{(RedisValue)"*"}"); + using var frame = ctx.Render("FT.SEARCH", $"{(RedisValue)"idx"}{(RedisValue)"*"}"); Assert.Equal("*3|$9|FT.SEARCH|$3|idx|$1|*|", Frame(frame)); } @@ -211,8 +211,8 @@ public void CreateValidatedRejectsMalformedBytes(string raw, int argCount) public void ValidatedFragmentsWriteLikeGeneratedOnes() { var ctx = new RespContext(); - using var generated = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX}"); - using var validated = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespFragment.CreateValidated("$2\r\nEX\r\n"u8)}"); + using var generated = ctx.Render(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespLiterals.EX}"); + using var validated = ctx.Render(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {RespFragment.CreateValidated("$2\r\nEX\r\n"u8)}"); Assert.True(generated.Span.SequenceEqual(validated.Span)); } diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs index b8f4dde95..b33d12887 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUnitTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Threading; @@ -51,7 +51,7 @@ private static string[] Keys(in RespFrame frame) public void RendersCommandKeyAndValue() { var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"myvalue"}"); + using var frame = ctx.Render($"{RedisCommand.SET}{(RedisKey)"mykey"}{(RedisValue)"myvalue"}"); Assert.Equal(3, frame.ArgCount); Assert.Equal(new[] { "SET", "mykey", "myvalue" }, Parse(frame.Span)); @@ -61,7 +61,7 @@ public void RendersCommandKeyAndValue() public void RendersExactBytes() { var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"abc"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"abc"}"); Assert.Equal("*2\r\n$3\r\nGET\r\n$3\r\nabc\r\n", Encoding.UTF8.GetString(frame.Span.ToArray())); } @@ -93,7 +93,7 @@ public void CommandMapRenamesAreApplied() { var map = CommandMap.Create(new Dictionary { ["set"] = "xset" }); var ctx = new RespContext(map); - using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + using var frame = ctx.Render($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); Assert.Equal(new[] { "XSET", "k", "v" }, Parse(frame.Span)); } @@ -104,21 +104,21 @@ public void DisabledCommandThrows() var map = CommandMap.Create(new Dictionary { ["set"] = null }); var ctx = new RespContext(map); - Assert.Throws(() => ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}").Dispose()); + Assert.Throws(() => ctx.Render($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}").Dispose()); } [Fact] public void CommandMustComeFirst() { var ctx = new RespContext(); - Assert.Throws(() => ctx.Execute($"{(RedisKey)"k"}{RedisCommand.GET}").Dispose()); + Assert.Throws(() => ctx.Render($"{(RedisKey)"k"}{RedisCommand.GET}").Dispose()); } [Fact] public void KeyPrefixIsAppliedToTheWire() { var ctx = new RespContext().WithKeyPrefix("tenant7:"); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"user:1"}"); Assert.Equal(new[] { "GET", "tenant7:user:1" }, Parse(frame.Span)); Assert.Equal(new[] { "tenant7:user:1" }, Keys(frame)); @@ -130,7 +130,7 @@ public void KeyPrefixComposesWithAKeyThatAlreadyHasOne() // a key that already carries a prefix, as the KeyPrefixed* decorators produce today var prefixed = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("inner:"), "user:1"); var ctx = new RespContext().WithKeyPrefix("outer:"); - using var frame = ctx.Execute($"{RedisCommand.GET}{prefixed}"); + using var frame = ctx.Render($"{RedisCommand.GET}{prefixed}"); Assert.Equal(new[] { "GET", "outer:inner:user:1" }, Parse(frame.Span)); } @@ -142,8 +142,8 @@ public void DatabaseIsNotPartOfTheRenderedFrame() // on different databases. Cache identity therefore needs (frame, database) - the frame alone is not // enough, which is easy to miss because everything else that matters (prefix, renamed command, // arguments) IS in the bytes. - using var a = new RespContext(database: 0).Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); - using var b = new RespContext(database: 3).Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + using var a = new RespContext(database: 0).Render($"{RedisCommand.GET}{(RedisKey)"k"}"); + using var b = new RespContext(database: 3).Render($"{RedisCommand.GET}{(RedisKey)"k"}"); Assert.True(a.Span.SequenceEqual(b.Span)); Assert.Equal(0, new RespContext(database: 0).Database); @@ -157,8 +157,8 @@ public void BothPrefixMechanismsRenderIdenticalBytes() // They are different RedisKey VALUES - RedisKey.Equals compares the carried prefix - but they must // be indistinguishable on the wire, which is what lets the rendered frame serve as a cache key. var viaDecorator = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("tenant7:"), "user:1"); - using var a = new RespContext().Execute($"{RedisCommand.GET}{viaDecorator}"); - using var b = new RespContext(keyPrefix: "tenant7:").Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + using var a = new RespContext().Render($"{RedisCommand.GET}{viaDecorator}"); + using var b = new RespContext(keyPrefix: "tenant7:").Render($"{RedisCommand.GET}{(RedisKey)"user:1"}"); Assert.True(a.Span.SequenceEqual(b.Span)); Assert.Equal(new[] { "GET", "tenant7:user:1" }, Parse(a.Span)); @@ -174,10 +174,10 @@ public void ComposingBothPrefixMechanismsDoesNotAllocate() var decorated = RedisKey.WithPrefix(Encoding.UTF8.GetBytes("inner:"), "user:1"); var ctx = new RespContext(keyPrefix: "outer:"); - for (int i = 0; i < 64; i++) ctx.Execute($"{RedisCommand.GET}{decorated}").Dispose(); // warm the pool + for (int i = 0; i < 64; i++) ctx.Render($"{RedisCommand.GET}{decorated}").Dispose(); // warm the pool var before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 128; i++) ctx.Execute($"{RedisCommand.GET}{decorated}").Dispose(); + for (int i = 0; i < 128; i++) ctx.Render($"{RedisCommand.GET}{decorated}").Dispose(); var allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(allocated == 0, $"allocated {allocated} bytes over 128 renders"); @@ -188,7 +188,7 @@ public void ComposingBothPrefixMechanismsDoesNotAllocate() public void NestedWithKeyPrefixComposes() { var ctx = new RespContext().WithKeyPrefix("a:").WithKeyPrefix("b:"); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}"); Assert.Equal(new[] { "GET", "a:b:k" }, Parse(frame.Span)); } @@ -197,7 +197,7 @@ public void NestedWithKeyPrefixComposes() public void ChannelPrefixIsApplied() { var ctx = new RespContext(channelPrefix: new RedisChannel("app:", RedisChannel.PatternMode.Literal)); - using var frame = ctx.Execute($"{RedisCommand.PUBLISH}{new RedisChannel("news", RedisChannel.PatternMode.Literal)}{(RedisValue)"hi"}"); + using var frame = ctx.Render($"{RedisCommand.PUBLISH}{new RedisChannel("news", RedisChannel.PatternMode.Literal)}{(RedisValue)"hi"}"); Assert.Equal(new[] { "PUBLISH", "app:news", "hi" }, Parse(frame.Span)); } @@ -208,7 +208,7 @@ public void ChannelPrefixIsSkippedWhenTheChannelOptsOut() // keyspace notification channels are server-generated names, and opt out of the channel prefix var channel = new RedisChannel("__keyevent@0__:set", RedisChannel.RedisChannelOptions.IgnoreChannelPrefix); var ctx = new RespContext(channelPrefix: new RedisChannel("app:", RedisChannel.PatternMode.Literal)); - using var frame = ctx.Execute($"{RedisCommand.SUBSCRIBE}{channel}"); + using var frame = ctx.Render($"{RedisCommand.SUBSCRIBE}{channel}"); Assert.Equal(new[] { "SUBSCRIBE", "__keyevent@0__:set" }, Parse(frame.Span)); } @@ -217,7 +217,7 @@ public void ChannelPrefixIsSkippedWhenTheChannelOptsOut() public void NoKeysMeansNoSlotAndNoMarks() { var ctx = new RespContext(serverType: ServerType.Cluster); - using var frame = ctx.Execute($"{RedisCommand.ECHO}{(RedisValue)"hello"}"); + using var frame = ctx.Render($"{RedisCommand.ECHO}{(RedisValue)"hello"}"); Assert.True(frame.HasNoKeys); Assert.Empty(Keys(frame)); @@ -229,13 +229,13 @@ public void OneAndTwoKeysResolveWithoutScanning() { var ctx = new RespContext(); - using (var one = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k1"}")) + using (var one = ctx.Render($"{RedisCommand.GET}{(RedisKey)"k1"}")) { Assert.False(one.KeysNeedScan); Assert.Equal(new[] { "k1" }, Keys(one)); } - using var two = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"src"}{(RedisKey)"dst"}{(RedisValue)"m"}"); + using var two = ctx.Render($"{RedisCommand.SMOVE}{(RedisKey)"src"}{(RedisKey)"dst"}{(RedisValue)"m"}"); Assert.False(two.KeysNeedScan); Assert.Equal(new[] { "src", "dst" }, Keys(two)); } @@ -244,7 +244,7 @@ public void OneAndTwoKeysResolveWithoutScanning() public void ThreeKeysResolveViaTheBitmap() { var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + using var frame = ctx.Render($"{RedisCommand.DEL}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); // past the two inline offsets, so resolving needs a walk - but the keys ARE recoverable; the // writer records every key's argument index as well as the first two offsets @@ -258,7 +258,7 @@ public void ThreeKeysResolveViaTheBitmap() public void StandaloneSkipsSlotComputation() { var ctx = new RespContext(serverType: ServerType.Standalone); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"foo"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"foo"}"); Assert.Equal(ServerSelectionStrategy.NoSlot, frame.Slot); } @@ -267,7 +267,7 @@ public void StandaloneSkipsSlotComputation() public void ClusterFoldsTheSlotFromTheWrittenBytes() { var ctx = new RespContext(serverType: ServerType.Cluster); - using var frame = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"foo"}"); + using var frame = ctx.Render($"{RedisCommand.GET}{(RedisKey)"foo"}"); // published CLUSTER KEYSLOT value Assert.Equal(12182, frame.Slot); @@ -278,7 +278,7 @@ public void ClusterFoldsTheSlotFromTheWrittenBytes() public void SharedHashTagGivesOneSlot() { var ctx = new RespContext(serverType: ServerType.Cluster); - using var frame = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"{u1}:a"}{(RedisKey)"{u1}:b"}{(RedisValue)"m"}"); + using var frame = ctx.Render($"{RedisCommand.SMOVE}{(RedisKey)"{u1}:a"}{(RedisKey)"{u1}:b"}{(RedisValue)"m"}"); Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"{u1}:a"), frame.Slot); Assert.NotEqual(ServerSelectionStrategy.MultipleSlots, frame.Slot); @@ -288,7 +288,7 @@ public void SharedHashTagGivesOneSlot() public void CrossSlotKeysAreDetected() { var ctx = new RespContext(serverType: ServerType.Cluster); - using var frame = ctx.Execute($"{RedisCommand.SMOVE}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisValue)"m"}"); + using var frame = ctx.Render($"{RedisCommand.SMOVE}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisValue)"m"}"); Assert.Equal(ServerSelectionStrategy.MultipleSlots, frame.Slot); } @@ -299,8 +299,8 @@ public void SlotIsComputedFromThePrefixedKey() var plain = new RespContext(serverType: ServerType.Cluster); var prefixed = plain.WithKeyPrefix("tenant7:"); - using var a = plain.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); - using var b = prefixed.Execute($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + using var a = plain.Render($"{RedisCommand.GET}{(RedisKey)"user:1"}"); + using var b = prefixed.Render($"{RedisCommand.GET}{(RedisKey)"user:1"}"); Assert.NotEqual(a.Slot, b.Slot); Assert.Equal(ServerSelectionStrategy.GetHashSlot((RedisKey)"tenant7:user:1"), b.Slot); @@ -313,7 +313,7 @@ public void CancellationIsObservedAndTheBufferIsReturned() cts.Cancel(); var ctx = new RespContext().WithCancellationToken(cts.Token); - Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); + Assert.Throws(() => ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); } [Fact] @@ -331,7 +331,7 @@ public void CancellationTokenFlowsThroughWithClones() public void MultiByteAndEmptyPayloadsRoundTrip() { var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"naïve☃"}{(RedisValue)""}"); + using var frame = ctx.Render($"{RedisCommand.SET}{(RedisKey)"naïve☃"}{(RedisValue)""}"); Assert.Equal(new[] { "SET", "naïve☃", "" }, Parse(frame.Span)); } @@ -341,7 +341,7 @@ public void LargePayloadForcesBufferGrowthMidBuild() { var big = new string('x', 5000); var ctx = new RespContext(); - using var frame = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)big}"); + using var frame = ctx.Render($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)big}"); var args = Parse(frame.Span); Assert.Equal(big, args[2]); @@ -371,7 +371,7 @@ public void ComposeThenConditionallyAppend(bool withTtl, bool withNx, string exp if (withNx) cmd.AppendFormatted((RedisValue)"NX"); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(expected, string.Join("|", Parse(frame.Span))); Assert.Equal(expected.Split('|').Length, frame.ArgCount); } @@ -389,7 +389,7 @@ public void ComposedKeysStillTrackAndRoute() var cmd = ctx.Compose($"{RedisCommand.SMOVE}{(RedisKey)"{u}:src"}"); cmd.AppendFormatted((RedisKey)"{u}:dst"); // second key arrives AFTER the interpolation cmd.AppendFormatted((RedisValue)"m"); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(new[] { "SMOVE", "{u}:src", "{u}:dst", "m" }, Parse(frame.Span)); Assert.Equal(new[] { "{u}:src", "{u}:dst" }, Keys(frame)); @@ -403,7 +403,7 @@ public void ComposedHeaderGrowsWithLateArguments() var ctx = new RespContext(); var cmd = ctx.Compose($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); for (int i = 0; i < 9; i++) cmd.AppendFormatted((RedisValue)i); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(12, frame.ArgCount); Assert.StartsWith("*12\r\n", Encoding.UTF8.GetString(frame.Span.ToArray())); @@ -417,7 +417,7 @@ public void ComposeWithCommandArgument() { var ctx = new RespContext(); var cmd = ctx.Compose(RedisCommand.SET, $"{(RedisKey)"k"}{(RedisValue)"v"}"); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(new[] { "SET", "k", "v" }, Parse(frame.Span)); Assert.Equal(new[] { "k" }, Keys(frame)); @@ -427,7 +427,7 @@ public void ComposeWithCommandArgument() public void ExecuteWithCommandArgument() { var ctx = new RespContext(serverType: ServerType.Cluster); - using var frame = ctx.Execute(RedisCommand.GET, $"{(RedisKey)"foo"}"); + using var frame = ctx.Render(RedisCommand.GET, $"{(RedisKey)"foo"}"); Assert.Equal(new[] { "GET", "foo" }, Parse(frame.Span)); Assert.Equal(12182, frame.Slot); @@ -441,7 +441,7 @@ public void ComposeWithNoInterpolationAtAll() var ctx = new RespContext(); var cmd = ctx.Compose(RedisCommand.DEL, keys.Length); foreach (var key in keys) cmd.AppendFormatted(key); - using var frame = ctx.Execute(ref cmd); + using var frame = ctx.Render(ref cmd); Assert.Equal(new[] { "DEL", "a", "b", "c" }, Parse(frame.Span)); Assert.Equal(4, frame.ArgCount); @@ -459,7 +459,7 @@ public void DisabledCommandThrowsFromBothInitializerForms() var ctx = new RespContext(map); Assert.Throws(() => ctx.Compose(RedisCommand.GET, 0).Dispose()); - Assert.Throws(() => ctx.Execute($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); + Assert.Throws(() => ctx.Render($"{RedisCommand.GET}{(RedisKey)"k"}").Dispose()); } // ---- the single-space relaxation --------------------------------------------------------------- @@ -468,8 +468,8 @@ public void DisabledCommandThrowsFromBothInitializerForms() public void SingleSpacesAreAllowedAndDiscarded() { var ctx = new RespContext(); - using var spaced = ctx.Execute($"{RedisCommand.SET} {(RedisKey)"k"} {(RedisValue)"v"}"); - using var tight = ctx.Execute($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); + using var spaced = ctx.Render($"{RedisCommand.SET} {(RedisKey)"k"} {(RedisValue)"v"}"); + using var tight = ctx.Render($"{RedisCommand.SET}{(RedisKey)"k"}{(RedisValue)"v"}"); // identical bytes: the space is a literal segment, not an argument Assert.True(spaced.Span.SequenceEqual(tight.Span)); @@ -486,8 +486,8 @@ public void LiteralsBecomeArgumentsRatherThanBeingDiscarded() // the readable spelling works and the analyzer only warns that it resolves per call var ctx = new RespContext(); - using var twoSpaces = ctx.Execute($"{RedisCommand.GET} {(RedisKey)"k"}"); - using var hyphen = ctx.Execute($"{RedisCommand.GET}-{(RedisKey)"k"}"); + using var twoSpaces = ctx.Render($"{RedisCommand.GET} {(RedisKey)"k"}"); + using var hyphen = ctx.Render($"{RedisCommand.GET}-{(RedisKey)"k"}"); // whitespace-only is still nothing; anything else is now an argument Assert.Equal(new[] { "GET", "k" }, Parse(twoSpaces.Span)); @@ -503,7 +503,7 @@ public void ALiteralCommandNowSuppliesTheCommand() // this used to throw: "SET " was discarded, so nothing supplied a command and the key could not be // framed. The leading token is now the command, so it renders exactly like the hole form. var ctx = new RespContext(); - using var frame = ctx.Execute($"SET {(RedisKey)"k"}"); + using var frame = ctx.Render($"SET {(RedisKey)"k"}"); Assert.Equal(new[] { "SET", "k" }, Parse(frame.Span)); } #pragma warning restore SER309 diff --git a/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs b/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs index 9548ab3ab..11a92f907 100644 --- a/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs +++ b/tests/StackExchange.Redis.Tests/InterpolatedWriterUsingStaticTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using StackExchange.Redis.Interpolated; using Xunit; @@ -34,7 +34,7 @@ public void ImportedFragmentsReadAlmostLikeInlineTokens() { // $"{key} {Nx} {value}" against the inline form it replaces, $"{key} nx {value}" var ctx = new RespContext(); - using var frame = ctx.Execute(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {Nx} {Ex} {(RedisValue)300}"); + using var frame = ctx.Render(RedisCommand.SET, $"{(RedisKey)"k"} {(RedisValue)"v"} {Nx} {Ex} {(RedisValue)300}"); Assert.Equal("*6|$3|SET|$1|k|$1|v|$2|NX|$2|EX|$3|300|", Frame(frame)); Assert.Equal(6, frame.ArgCount); @@ -44,8 +44,8 @@ public void ImportedFragmentsReadAlmostLikeInlineTokens() public void ImportedAndQualifiedAreTheSame() { var ctx = new RespContext(); - using var imported = ctx.Execute(RedisCommand.CONFIG, $"{Get} {(RedisValue)"maxmemory"}"); - using var qualified = ctx.Execute(RedisCommand.CONFIG, $"{RespLiterals.Get} {(RedisValue)"maxmemory"}"); + using var imported = ctx.Render(RedisCommand.CONFIG, $"{Get} {(RedisValue)"maxmemory"}"); + using var qualified = ctx.Render(RedisCommand.CONFIG, $"{RespLiterals.Get} {(RedisValue)"maxmemory"}"); Assert.True(imported.Span.SequenceEqual(qualified.Span)); Assert.Equal("*3|$6|CONFIG|$3|GET|$9|maxmemory|", Frame(imported)); diff --git a/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs b/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs index a3f1404fb..32722eab6 100644 --- a/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs +++ b/tests/StackExchange.Redis.Tests/MessageToRespFrameTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using StackExchange.Redis.Interpolated; @@ -40,7 +40,7 @@ public void MessageRendersTheSameBytesAsTheInterpolatedWriter() using var viaMessage = Render(Message.Create(0, CommandFlags.None, RedisCommand.GET, (RedisKey)"mykey")); var ctx = new RespContext(); - using var viaInterpolation = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"mykey"}"); + using var viaInterpolation = ctx.Render($"{RedisCommand.GET}{(RedisKey)"mykey"}"); // byte-identical rendering is not a nicety here: the frame IS the cache key, so two routes that // disagree would cache the same logical command twice @@ -129,7 +129,7 @@ public void AMessageRenderCanBeCachedAndInvalidated() // and a render from the OTHER route finds it - the two are interchangeable as cache keys var ctx = new RespContext(); - using var probe = ctx.Execute($"{RedisCommand.GET}{(RedisKey)"mykey"}"); + using var probe = ctx.Render($"{RedisCommand.GET}{(RedisKey)"mykey"}"); Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var hit)); Assert.Equal("$5|hello|", Text(hit.Span)); hit.Release(); diff --git a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs index 3f1bfbf51..dccd9c043 100644 --- a/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs +++ b/tests/StackExchange.Redis.Tests/RespCacheInvalidationTests.cs @@ -22,6 +22,10 @@ namespace StackExchange.Redis.Tests; /// then this is the shape a caller would have to use, and it exercises exactly the same routing. /// /// +// CLIENT TRACKING here is not scoped to this class: a FLUSHDB sends an UNFILTERABLE flush push to every +// tracking client on the server, including the ones RespTrackingTests is counting invalidations on. That is +// the design working as documented (6.13) and a test interfering with another test, so these do not overlap. +[Collection(NonParallelCollection.Name)] public class RespCacheInvalidationTests(ITestOutputHelper output) : TestBase(output) { private static async Task WaitFor(Func> condition, int millis = 3000) diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index a4328ead6..fc8c5621c 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -20,7 +20,7 @@ public class RespClientCacheTests private static RespContext Via(IRespExecutor executor, RespClientCache? cache = null) => new RespContext().WithExecutor(executor).WithCache(cache); - private static RespFrame Get(string key) => Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + private static RespFrame Get(string key) => Ctx.Render($"{RedisCommand.GET}{(RedisKey)key}"); private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); @@ -222,7 +222,7 @@ public void ThreeKeyCommandsCacheAndInvalidateOnAnyKey(int which) { using var cache = new RespClientCache(); - var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); Assert.True(frame.KeysNeedScan); // beyond the two inline offsets: resolved from the bitmap Assert.Equal(3, frame.KeyCount); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); @@ -234,7 +234,7 @@ public void ThreeKeyCommandsCacheAndInvalidateOnAnyKey(int which) static bool ThreeKeyHit(RespClientCache cache) { - using var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + using var probe = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); if (!cache.TryGet(probe.AsLookupKey(), 0, out var payload)) return false; payload.Release(); return true; @@ -246,11 +246,11 @@ public void BitmapResolvesTheSameRangesTheOffsetsWould() { // the two encodings must agree where they overlap, or a frame's keys would depend on how many // other keys happened to be present - using var two = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}"); + using var two = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}"); Assert.False(two.KeysNeedScan); Assert.Equal(new[] { "alpha", "beta" }, KeyStrings(two)); - using var three = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisKey)"gamma"}"); + using var three = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"alpha"}{(RedisKey)"beta"}{(RedisKey)"gamma"}"); Assert.True(three.KeysNeedScan); Assert.Equal(new[] { "alpha", "beta", "gamma" }, KeyStrings(three)); } @@ -259,7 +259,7 @@ public void BitmapResolvesTheSameRangesTheOffsetsWould() public void KeysAreFoundAmongNonKeyArguments() { // the bitmap indexes ARGUMENTS, so values interleaved with keys must not shift the walk - using var frame = Ctx.Execute( + using var frame = Ctx.Render( $"{RedisCommand.MSET}{(RedisKey)"k1"}{(RedisValue)"v1"}{(RedisKey)"k2"}{(RedisValue)"v2"}{(RedisKey)"k3"}{(RedisValue)"v3"}"); Assert.Equal(3, frame.KeyCount); Assert.Equal(new[] { "k1", "k2", "k3" }, KeyStrings(frame)); @@ -287,7 +287,7 @@ public void KeysBeyondTheBitmapAreReportedAsUnavailableNotAsASubset() [Fact] public void TooSmallATargetIsRejectedRatherThanTruncated() { - using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + using var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); Span small = stackalloc KeyRange[2]; Assert.Equal(-1, frame.TryGetKeys(small)); @@ -499,7 +499,7 @@ public void KeylessCommandsAreNeverCached() // a keyless command can NEVER be invalidated: server-assisted invalidation only ever reports keys, // so an entry with no dependencies is vacuously valid forever. Not even a FLUSHALL clears it, // because OnFlush stamps key nodes and this entry has none. Permanent staleness - refuse it. - var frame = Ctx.Execute($"{RedisCommand.TIME}"); + var frame = Ctx.Render($"{RedisCommand.TIME}"); Assert.Equal(0, frame.KeyCount); Assert.False(cache.TryBeginFill(ref frame, 0, out _)); frame.Dispose(); @@ -595,7 +595,7 @@ public void RefusalCountersSayWhyNothingWasCached() cache.TryBeginFill(ref undeclared, 0, CommandFlags.None, out _); undeclared.Dispose(); - var keyless = Ctx.Execute($"{RedisCommand.TIME}"); + var keyless = Ctx.Render($"{RedisCommand.TIME}"); cache.TryBeginFill(ref keyless, 0, CommandFlags.CommandRetryReadOnly, out _); keyless.Dispose(); @@ -736,7 +736,7 @@ public void ADetachedRequestCanStillAnswerForItself() { // the point of the widening: an executor decorator sees a RespRequest and nothing else, so the // request has to carry what routing, retry and caching each need to ask - using var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); + using var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}{(RedisKey)"c"}"); using var request = frame.Detach(CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache); Assert.Equal(4, request.ArgCount); @@ -754,7 +754,7 @@ public void ADetachedRequestCanStillAnswerForItself() public void RoutingNeedsOnlyTheSlotAndTheRequestCarriesIt() { var cluster = new RespContext(serverType: ServerType.Cluster); - using var frame = cluster.Execute($"{RedisCommand.GET}{(RedisKey)"{tag}:x"}"); + using var frame = cluster.Render($"{RedisCommand.GET}{(RedisKey)"{tag}:x"}"); var slot = frame.Slot; Assert.NotEqual(ServerSelectionStrategy.NoSlot, slot); @@ -789,11 +789,11 @@ public void MultiKeyEntryIsInvalidatedByAnyOfItsKeys() { using var cache = new RespClientCache(); - var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}"); + var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}"); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); Assert.True(Complete(cache, fill, "*2\r\n$1\r\n1\r\n$1\r\n2\r\n")); - using (var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) + using (var probe = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) { Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var payload)); payload.Release(); @@ -801,7 +801,7 @@ public void MultiKeyEntryIsInvalidatedByAnyOfItsKeys() cache.OnInvalidate(Utf8("b")); // the SECOND key - using (var probe = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) + using (var probe = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"a"}{(RedisKey)"b"}")) { Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _)); } @@ -869,7 +869,7 @@ public void UntrackedKeysAreNotCached(string key, bool cacheable) { using var cache = new RespClientCache(new CacheOptions { Prefixes = ["app:", "session:"] }); - var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)key}"); + var frame = Ctx.Render($"{RedisCommand.GET}{(RedisKey)key}"); var admitted = cache.TryBeginFill(ref frame, 0, out var fill); Assert.Equal(cacheable, admitted); if (admitted) @@ -898,13 +898,13 @@ public void OneUntrackedKeySpoilsAMultiKeyCommand() { using var cache = new RespClientCache(new CacheOptions { Prefixes = ["app:"] }); - var frame = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"other"}"); + var frame = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"other"}"); Assert.False(cache.TryBeginFill(ref frame, 0, out _)); frame.Dispose(); Assert.Equal(1, cache.RefusedNotTracked); // ...and the same command with every key inside the set is fine - var ok = Ctx.Execute($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"app:c"}"); + var ok = Ctx.Render($"{RedisCommand.MGET}{(RedisKey)"app:a"}{(RedisKey)"app:b"}{(RedisKey)"app:c"}"); Assert.True(cache.TryBeginFill(ref ok, 0, out var fill)); Assert.True(Complete(cache, fill, "*3\r\n$1\r\n1\r\n$1\r\n2\r\n$1\r\n3\r\n")); } @@ -915,7 +915,7 @@ public void NoPrefixesMeansEverythingIsCacheable() { using var cache = new RespClientCache(new CacheOptions { DefaultPolicy = new CachePolicy() }); // the default: BCAST with no prefix - var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"anything at all"}"); + var frame = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"anything at all"}"); Assert.True(cache.TryBeginFill(ref frame, 0, out var fill)); Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); Assert.Equal(0, cache.RefusedNotTracked); @@ -965,11 +965,11 @@ public void PrefixesMatchWholeBytesNotCharacters() using var cache = new RespClientCache(new CacheOptions { Prefixes = ["é:"] }); // 0xC3 0xA9 // a key starting with the first byte of the prefix but not the second must not match - var frame = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"è:x"}"); // 0xC3 0xA8 + var frame = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"è:x"}"); // 0xC3 0xA8 Assert.False(cache.TryBeginFill(ref frame, 0, out _)); frame.Dispose(); - var ok = Ctx.Execute($"{RedisCommand.GET}{(RedisKey)"é:x"}"); + var ok = Ctx.Render($"{RedisCommand.GET}{(RedisKey)"é:x"}"); Assert.True(cache.TryBeginFill(ref ok, 0, out var fill)); Assert.True(Complete(cache, fill, "$1\r\nx\r\n")); } diff --git a/tests/StackExchange.Redis.Tests/RespCommandTests.cs b/tests/StackExchange.Redis.Tests/RespCommandTests.cs index 540f3f545..c5b426bf3 100644 --- a/tests/StackExchange.Redis.Tests/RespCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/RespCommandTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using StackExchange.Redis.Interpolated; @@ -22,7 +22,7 @@ public void KnownCommandsStayDeferredSoTheMapStillApplies() Assert.False(get.IsPreformed); // the map holds the bytes, and only the map can var renamed = CommandMap.Create(new Dictionary { ["GET"] = "FETCH" }); - using var frame = new RespContext(renamed).Execute($"{get}{(RedisKey)"k"}"); + using var frame = new RespContext(renamed).Render($"{get}{(RedisKey)"k"}"); Assert.Equal("*2|$5|FETCH|$1|k|", Text(frame)); } @@ -36,7 +36,7 @@ public void PreformingAKnownCommandWouldNotBypassTheMap() var ctx = new RespContext(disabled); Assert.Throws(() => { - using var frame = ctx.Execute($"{"GET".Command(preform: true)}{(RedisKey)"k"}"); + using var frame = ctx.Render($"{"GET".Command(preform: true)}{(RedisKey)"k"}"); }); } @@ -49,7 +49,7 @@ public void UnknownCommandsRenderIdenticallyEitherWay(bool preform) Assert.False(search.IsKnown); Assert.Equal(preform, search.IsPreformed); - using var frame = new RespContext().Execute($"{search}{(RedisValue)"idx"}"); + using var frame = new RespContext().Render($"{search}{(RedisValue)"idx"}"); Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); } @@ -59,7 +59,7 @@ public void UnknownCommandsAreUnaffectedByTheCommandMap() // CommandMap is built by walking the RedisCommand enum, so an override on a name it cannot parse is // silently ignored - which is why preforming a module command is safe var renamed = CommandMap.Create(new Dictionary { ["FT.SEARCH"] = "FT.SRCH" }); - using var frame = new RespContext(renamed).Execute($"{"FT.SEARCH".Command()}{(RedisValue)"idx"}"); + using var frame = new RespContext(renamed).Render($"{"FT.SEARCH".Command()}{(RedisValue)"idx"}"); Assert.Equal("*2|$9|FT.SEARCH|$3|idx|", Text(frame)); } @@ -71,7 +71,7 @@ public void ACommandCanAlsoBeAnArgumentNamingACommand() var renamed = CommandMap.Create(new Dictionary { ["HGET"] = "HASHGET" }); var ctx = new RespContext(renamed); - using var frame = ctx.Execute($"{"COMMAND".Command()}{RespLiterals.Info}{"HGET".Command()}"); + using var frame = ctx.Render($"{"COMMAND".Command()}{RespLiterals.Info}{"HGET".Command()}"); Assert.Equal("*3|$7|COMMAND|$4|INFO|$7|HASHGET|", Text(frame)); } @@ -95,8 +95,8 @@ public void Utf8LiteralsNeedNoStringAtAll() var fromBytes = "FT.SEARCH"u8.Command(); Assert.False(fromBytes.IsKnown); - using var a = new RespContext().Execute($"{fromString}{(RedisValue)"idx"}"); - using var b = new RespContext().Execute($"{fromBytes}{(RedisValue)"idx"}"); + using var a = new RespContext().Render($"{fromString}{(RedisValue)"idx"}"); + using var b = new RespContext().Render($"{fromBytes}{(RedisValue)"idx"}"); Assert.Equal(Text(a), Text(b)); } @@ -107,7 +107,7 @@ public void Utf8LiteralsAlsoResolveKnownCommandsThroughTheMap() Assert.True("GET"u8.Command().IsKnown); var renamed = CommandMap.Create(new Dictionary { ["GET"] = "FETCH" }); - using var frame = new RespContext(renamed).Execute($"{"GET"u8.Command()}{(RedisKey)"k"}"); + using var frame = new RespContext(renamed).Render($"{"GET"u8.Command()}{(RedisKey)"k"}"); Assert.Equal("*2|$5|FETCH|$1|k|", Text(frame)); } diff --git a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs index d4ed682f2..79b12ba57 100644 --- a/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RespEndToEndTests.cs @@ -179,7 +179,7 @@ public async Task SynchronousFireAndForgetReturnsDefaultRatherThanThrowing() const CommandFlags Flags = CommandFlags.CommandRetryWriteLastWins | CommandFlags.FireAndForget; var context = ((IRespTarget)db).Context; - var frame = context.Execute($"{RedisCommand.SET}{(RedisKey)key}{(RedisValue)"marc"}"); + var frame = context.Render($"{RedisCommand.SET}{(RedisKey)key}{(RedisValue)"marc"}"); Assert.False(context.Send(ref frame, Flags, RespHandlers.Boolean)); // and it really was sent, rather than quietly swallowed diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs index a3a06ff29..465ef1261 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs @@ -502,7 +502,7 @@ public async Task TheFrameCarriesTheCommandsIdentityAsWellAsItsBytes() // not decoration: the pipeline decides primary-vs-replica routing from Message.Command, and a // profiler reports it. A frame that only knew its bytes reported every command as UNKNOWN. - using var frame = ctx.Execute($"{RedisCommand.GETRANGE}{(RedisKey)"k"}{(RedisValue)0}{(RedisValue)(-1)}"); + using var frame = ctx.Render($"{RedisCommand.GETRANGE}{(RedisKey)"k"}{(RedisValue)0}{(RedisValue)(-1)}"); Assert.Equal(RedisCommand.GETRANGE, frame.Command); await Task.CompletedTask; diff --git a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs index 9e7d472fc..457f15ec6 100644 --- a/tests/StackExchange.Redis.Tests/RespTrackingTests.cs +++ b/tests/StackExchange.Redis.Tests/RespTrackingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Text; using System.Threading.Tasks; @@ -16,6 +16,7 @@ namespace StackExchange.Redis.Tests; /// the cache is tested against fakes, which can prove the logic but not that a server's invalidation reaches /// us, nor that the key bytes it names match the ones we recorded when we wrote the command. /// +[Collection(NonParallelCollection.Name)] // see RespCacheInvalidationTests: flush pushes cross connections public class RespTrackingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { /// From 2c905046db9c7de904af239e63081f4666ec8a56 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 04:53:54 +0100 Subject: [PATCH 140/360] Wipe pooled arrays whose elements can hold references Prerequisite for the lease returns: ReadOnlyLease began life as ReadOnlyLease, where returning a pooled array unwiped is correct and clearing would be pure cost. The moment T is RedisValue, HashEntry or SortedSetEntry that stops being true - a returned array still points at everything that was in it, so each of those objects stays reachable until the buffer happens to be rented and overwritten. It never throws; the heap just quietly fails to shrink, which is much harder to find than a crash. RuntimeHelpers.IsReferenceOrContainsReferences answers this exactly but does not exist on net461/netstandard2.0, so down-level errs towards clearing: a needless wipe costs a memset and a missed one costs retention, which are not the same size of mistake. Both directions are pinned - never-clear fails the reference test, always-clear fails the primitive one - because this is a decision, not a default. --- src/StackExchange.Redis/ReadOnlyLease.cs | 29 ++++++++- .../ReadOnlyLeaseTests.cs | 65 ++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/StackExchange.Redis/ReadOnlyLease.cs b/src/StackExchange.Redis/ReadOnlyLease.cs index 7cf5b6a6c..c44660147 100644 --- a/src/StackExchange.Redis/ReadOnlyLease.cs +++ b/src/StackExchange.Redis/ReadOnlyLease.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Threading; @@ -113,6 +113,31 @@ internal static ReadOnlyLease Share(IMemoryOwner owner, int offset, int le private static T[] ThrowDisposed() => throw new ObjectDisposedException(nameof(ReadOnlyLease)); + /// + /// Whether a returned array must be wiped before it goes back to the pool. + /// + /// + /// + /// Only when can hold a reference. For byte - the only element type + /// this began with - clearing is pure cost and buys nothing. For , + /// and friends it is not optional: a pooled array that is handed back still + /// points at whatever was in it, so every one of those objects stays reachable until the buffer + /// happens to be rented and overwritten. That is not a leak that ever throws; it is a heap that + /// quietly does not shrink, which is materially harder to find. + /// + /// + /// RuntimeHelpers.IsReferenceOrContainsReferences answers this exactly, but does not exist on + /// net461/netstandard2.0. Down-level the fallback errs towards clearing: a needless + /// wipe costs a memset, a missed one costs retention, and those are not the same size of mistake. + /// + /// + private static readonly bool ClearOnReturn = +#if NET + System.Runtime.CompilerServices.RuntimeHelpers.IsReferenceOrContainsReferences(); +#else + !(typeof(T).IsPrimitive || typeof(T).IsEnum); +#endif + /// Release the memory owned or referenced by this lease. /// /// Exchange-to-null makes this once-only however many times it is called, which matters because the @@ -126,7 +151,7 @@ public void Dispose() switch (buffer) { case T[] array: - ArrayPool.Shared.Return(array); + ArrayPool.Shared.Return(array, ClearOnReturn); break; case IMemoryOwner owner: owner.Dispose(); diff --git a/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs b/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs index 29c70c4ad..d2f91f98e 100644 --- a/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs +++ b/tests/StackExchange.Redis.Tests/ReadOnlyLeaseTests.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Buffers; using System.Text; using RESPite.Messages; using StackExchange.Redis; @@ -117,4 +118,66 @@ public void AnEmptyScalarIsTheSharedEmpty() Assert.Same(ReadOnlyLease.Empty, lease); Assert.Equal(1, reply.RefCount); // nothing to share } + /// + /// A lease over elements that can hold references wipes the array before returning it to the pool. + /// + /// + /// + /// Not fussiness: a pooled array handed back still points at everything that was in it, so each of + /// those objects stays reachable until that buffer happens to be rented again and overwritten. It never + /// throws; the heap just quietly fails to shrink, which is far harder to find than a crash. + /// + /// + /// Asserted by renting the same size straight back - the shared pool hands out the most recently + /// returned buffer of a bucket - and looking at what is in it. That is an implementation detail of + /// rather than a contract, which is why the test tolerates getting a + /// different array and only asserts when it got the same one back. + /// + /// + [Fact] + public void ReferenceElementsAreClearedOnReturn() + { + var lease = ReadOnlyLease.Rent(4, null, out var target); + for (var i = 0; i < target.Length; i++) target[i] = "value" + i; + lease.Dispose(); + + var reused = ArrayPool.Shared.Rent(4); + try + { + foreach (var slot in reused) + { + Assert.Null(slot); + } + } + finally + { + ArrayPool.Shared.Return(reused); + } + } + + /// ...and a primitive element type is not wiped, because clearing it buys nothing. + /// + /// The cost side of the same decision: bytes cannot keep anything alive, so a memset per release would + /// be pure overhead on the path this type was built for in the first place. + /// + [Fact] + public void PrimitiveElementsAreNotClearedOnReturn() + { + var lease = ReadOnlyLease.Rent(4, null, out var target); + target.Fill(0xAB); + lease.Dispose(); + + var reused = ArrayPool.Shared.Rent(4); + try + { + // if this ever legitimately hands back a different buffer, the assertion below is vacuous + // rather than wrong - which is the right way round for a test about an optimisation + Assert.Contains(reused, b => b == 0xAB); + } + finally + { + ArrayPool.Shared.Return(reused); + } + } + } From ae3abb1ef3c9a3ec954e9a646875c6f10e5efa54 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 04:57:17 +0100 Subject: [PATCH 141/360] MGET returns a pooled lease; the array shape moves to an internal sibling The worked example for getting arrays off this surface. Strings.Get(keys) now returns ReadOnlyLease, which the caller gives back, and the array form lives on an internal GetArray that TransitionalDatabase calls. A sibling rather than a conversion, and that is the whole point: IDatabase promises an array the caller owns, so bridging through the lease would rent a pooled buffer only to copy out of it and hand it straight back - strictly worse than allocating the array in the first place. Two handlers over one command costs one duplicated interpolated line and no knowledge. Internal because it serves a shape on its way out; when that goes, so does this, with no binary consequence because nothing outside the assembly could ever bind to it. What this saves is the array, not its contents: RedisValue has no lifetime and cannot be given one, so the elements still allocate. On a large MGET the array is the part that reaches gen 2. 28 array returns left, all the same transformation. --- design/interpolated-resp-writer.queue.md | 17 +++-- .../Interpolated/RespSurface.Strings.cs | 37 ++++++++++- .../Interpolated/RespSurface.cs | 47 ++++++++++++++ .../TransitionalDatabase.Strings.cs | 10 ++- .../PublicAPI/PublicAPI.Unshipped.txt | 3 +- .../RespSurfaceStringsTests.cs | 62 ++++++++++++++++++- 6 files changed, 163 insertions(+), 13 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 65d0150ed..bebfd97f3 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -25,7 +25,7 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Next -- [ ] **Get the arrays off the new API.** There should be very close to zero. Counted today: 32 array +- [ ] **Get the arrays off the new API.** There should be very close to zero. Counted at the start: 32 array occurrences on the `SER010`/`SER011` surface, of which **29 are `ValueTask` returns** - `RedisValue[]`, `HashEntry[]`, `SortedSetEntry[]`, `double?[]`, `long[]`, `bool[]`, `ExpireResult[]`, `PersistResult[]`. Inherited wholesale from the old surface, where there was no @@ -53,8 +53,15 @@ a line saying why, because "we decided not to" is worth as much as "we did". **The one real exception:** `RespAttribute` - `params string[]` and `Tokens`. Attribute arguments must be arrays; the CLR gives no choice. Worth stating so it is not "fixed" by someone later. - Blocked on nothing, but it wants `Parse(ref RespReader)` (below) to land first or alongside: filling - a pooled buffer straight from the reader is the mechanism, and doing it twice would be silly. + **Worked example landed:** `Strings.Get(keys)` (MGET) now returns `ReadOnlyLease`, with + an internal `GetArray` sibling for `TransitionalDatabase`, and `RespHandlers.ValueLease` beside + `RespHandlers.Values`. 28 array returns left, all the same transformation. Prerequisite found and + fixed on the way: `ReadOnlyLease` was returning pooled arrays unwiped, which is right for `byte` + and retention for any `T` holding a reference. + + It does not need `Parse(ref RespReader)` after all - a handler can fill a rented span from a span + reply perfectly well - but the two still compose, and doing the remaining groups after that lands + would avoid touching each handler twice. **Satisfying the old API, which still says `T[]`.** `TransitionalDatabase` has to keep returning arrays, so something has to bridge. The obvious move - give `ReadOnlyLease` an internal "hand me @@ -178,7 +185,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Refuse to cache keys outside the tracked prefixes: no announcement, no invalidation path — `e42c8d22` - [x] Fire-and-forget is neither cached nor served; sync F+F no longer throws `"No reply."` — `abd87708` - [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` -- [x] `Execute` -> `Render` on the context: rendering is not executing — this change +- [x] `Execute` -> `Render` on the context: rendering is not executing — `682cc687` +- [x] Wipe pooled arrays whose elements can hold references — `2c905046` +- [x] MGET returns a pooled lease; the array shape moves to an internal sibling — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` - [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 588bcafa4..0ff16a9ee 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -113,12 +113,43 @@ public static ValueTask Get(this in RespStrings strings, RedisKey ke /// keys" is an empty array without asking anyone. The send is skipped, so this completes /// synchronously and allocates nothing. /// + /// + /// A pooled lease, not an array, and it must be disposed. The reply is a block of values the + /// caller almost always walks once, so handing over an array means a per-call allocation that + /// nothing can reclaim; a lease can be given back. The elements still allocate - RedisValue + /// has no lifetime - so what this saves is the array, which on a large MGET is the part + /// that reaches gen 2. The array shape the old surface still needs lives on the internal GetArray sibling. + /// + /// + public static ValueTask> Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + => keys.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : strings.Context.SendAsync( + $"{RedisCommand.MGET}{keys}", flags.WithDefaultCategory(RedisCommand.MGET), RespHandlers.ValueLease); + + /// MGET, as an array, for the old IDatabase surface. + /// The string command group. + /// The keys to read. + /// Command flags. + /// + /// + /// Internal, and deliberately a sibling rather than a conversion. IDatabase.StringGet + /// promises an array the caller owns outright, so bridging through Get would rent a + /// pooled buffer only to copy out of it and hand it straight back - strictly worse than allocating + /// the array in the first place. Two handlers over one command costs one duplicated interpolated + /// line and no knowledge; the command is still written once anywhere it matters. + /// + /// + /// Internal because it must never reach the public surface: it exists to serve a shape that is on + /// its way out, and when the old surface goes, so does this - with no binary consequence, because + /// nothing outside this assembly could ever have bound to it. + /// /// - public static ValueTask Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + internal static ValueTask GetArray(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) => keys.IsEmpty ? new ValueTask(Array.Empty()) - : strings.Context.SendAsync( - $"{RedisCommand.MGET}{keys}", flags.WithDefaultCategory(RedisCommand.MGET)); + : strings.Context.SendAsync( + $"{RedisCommand.MGET}{keys}", flags.WithDefaultCategory(RedisCommand.MGET), RespHandlers.Values); /// GET, retaining the payload as a rather than a value. /// The string command group. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 13aa01307..7187c50b0 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -58,6 +58,14 @@ public static class RespHandlers /// Reads an array reply as s; a nil array reads as empty. public static IRespHandler Values { get; } = DefaultHandlers.Instance; + /// Reads an array reply into a pooled the caller gives back. + /// + /// What the new surface returns, where is what the old one needs. See the + /// queue notes on getting arrays off this API: the difference is who owns the storage, not what is + /// in it. + /// + public static IRespHandler> ValueLease { get; } = DefaultHandlers.Instance; + /// Reads a bulk string reply as a ; null stays null. public static IRespHandler String { get; } = DefaultHandlers.Instance; @@ -161,6 +169,7 @@ private sealed class DefaultHandlers : IRespHandler?>, IRespHandler, IRespHandler, + IRespHandler>, IRespHandler, IRespHandler, IRespHandler, @@ -223,6 +232,44 @@ double IRespHandler.Parse(ReadOnlySpan response) return reader.ReadDouble(); } + /// + /// The pooled counterpart of the array handler below. Same reply, same elements; what differs is + /// that the caller can give the storage back, which on a large MGET is the part that + /// would otherwise reach gen 2. The elements themselves still allocate - RedisValue has + /// no lifetime and cannot be handed one - so this saves the array, not its contents. + /// + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + + // as the array handler: a nil aggregate reads as empty, because every caller of an array + // reply wants to iterate it + if (reader.IsNull) return ReadOnlyLease.Empty; + + var count = reader.AggregateLength(); + if (count <= 0) return ReadOnlyLease.Empty; + + var lease = ReadOnlyLease.Rent(count, null, out var target); + try + { + var children = reader.AggregateChildren(); + var index = 0; + while (index < count && children.MoveNext()) + { + target[index++] = children.Value.ReadRedisValue(); + } + + return lease; + } + catch + { + // the lease is rented by now, and nobody else has a reference to give back + lease.Dispose(); + throw; + } + } + RedisValue[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs index 962193aeb..46929244e 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Strings.cs @@ -117,12 +117,16 @@ public Task StringGetSetAsync(RedisKey key, RedisValue value, Comman => Context.Strings.SetAndGet(key, value, flags: flags).AsTask(); /// + /// + /// GetArray, not Get: this signature promises an array the caller owns, so it takes + /// the sibling that produces one directly rather than renting a lease and copying out of it. + /// public RedisValue[] StringGet(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => Wait(Context.Strings.Get(Required(keys, nameof(keys)), flags)); + => Wait(Context.Strings.GetArray(Required(keys, nameof(keys)), flags)); - /// + /// public Task StringGetAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => Context.Strings.Get(Required(keys, nameof(keys)), flags).AsTask(); + => Context.Strings.GetArray(Required(keys, nameof(keys)), flags).AsTask(); /// public Lease? StringGetLease(RedisKey key, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f6c9ff181..9d10b3bd6 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -230,7 +230,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetAll(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -389,3 +389,4 @@ static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redi [SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.init -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.Bytes.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.Evicted.get -> long +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ValueLease.get -> StackExchange.Redis.Interpolated.IRespHandler!>! diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs index 465ef1261..7e446d4d0 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceStringsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Text; using System.Threading; @@ -162,7 +163,7 @@ public async Task ManyKeysAreOneHoleAndStillKeys() var (ctx, exec) = Target("*2\r\n$1\r\na\r\n$1\r\nb\r\n"); RedisKey[] keys = ["k1", "k2", "k3"]; - await ctx.WithKeyPrefix("t:").Strings.Get(keys); + (await ctx.WithKeyPrefix("t:").Strings.Get(keys)).Dispose(); // every key in the run is prefixed, exactly as a single key is Assert.Equal("*4|$4|MGET|$4|t:k1|$4|t:k2|$4|t:k3|", Assert.Single(exec.Sent)); @@ -173,7 +174,13 @@ public async Task NoKeysMeansNoCommand() { var (ctx, exec) = Target(); - Assert.Empty(await ctx.Strings.Get(ReadOnlySpan.Empty)); + // the empty case hands back the shared Empty lease, so there is nothing pooled to give back - + // but it is disposed anyway, because a caller cannot know that and should not have to + using (var none = await ctx.Strings.Get(ReadOnlySpan.Empty)) + { + Assert.Equal(0, none.Length); + } + Assert.True(await ctx.Strings.Set(ReadOnlySpan>.Empty)); // an arity-zero MGET or MSET is a server error; "nothing" is answerable without asking @@ -507,4 +514,55 @@ public async Task TheFrameCarriesTheCommandsIdentityAsWellAsItsBytes() await Task.CompletedTask; } + /// MGET comes back as a pooled lease carrying the same values an array would. + /// + /// The point of the shape change is who owns the storage, not what is in it - so the values must be + /// indistinguishable from the array form, including the nulls that a missing key produces. + /// + [Fact] + public async Task MultiGetReturnsTheValuesAsALease() + { + var (ctx, _) = Target("*3\r\n$1\r\na\r\n_\r\n$2\r\nbc\r\n"); + + using var values = await ctx.Strings.Get([(RedisKey)"k1", (RedisKey)"k2", (RedisKey)"k3"]); + + Assert.Equal(3, values.Length); + Assert.Equal("a", values.Span[0]); + Assert.True(values.Span[1].IsNull); + Assert.Equal("bc", values.Span[2]); + } + + /// + /// The lease really is pooled: disposing one and asking again reuses the same storage. + /// + /// + /// Without this the shape change would be pure ceremony - a disposable wrapper around a fresh + /// allocation buys nothing and costs a using. Asserted through + /// rather than by identity, because the lease does not expose its buffer; renting the same size back + /// is the only observation available, and it is an implementation detail rather than a contract, so + /// this asserts only that a returned buffer is being offered again. + /// + [Fact] + public async Task TheLeaseStorageGoesBackToThePool() + { + var (ctx, _) = Target("*3\r\n$1\r\na\r\n$1\r\nb\r\n$1\r\nc\r\n"); + RedisKey[] keys = [(RedisKey)"k1", (RedisKey)"k2", (RedisKey)"k3"]; + + var first = await ctx.Strings.Get(keys); + Assert.Equal(3, first.Length); + first.Dispose(); + + // the shared pool hands back the most recently returned buffer of a bucket, so a rent of the same + // size should find one waiting - and it must have been wiped, since RedisValue holds a reference + var reused = ArrayPool.Shared.Rent(3); + try + { + Assert.All(reused, v => Assert.True(v.IsNull)); + } + finally + { + ArrayPool.Shared.Return(reused); + } + } + } From eddb3b5fca0e4b6edea644679939d72c664789c2 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:01:59 +0100 Subject: [PATCH 142/360] Measure when invalidations actually arrive Raw bytes off one socket against Redis 8.9.241, RESP3, BCAST PREFIX, so the order on the wire is the answer rather than an inference. Key invalidations trail their replies AND are accumulated across the write cycle rather than per command: two pipelined SETs produce one two-key push after both +OKs. MSET of three keys is one push of three. FLUSHDB is the exception - its invalidate null precedes its own +OK. The consequence that matters: a self-invalidation can never protect read-your-own-writes. SET k v followed by GET k, pipelined, returns the read BEFORE the notification about the write - so a cache holding a stale entry would answer from it and be corrected afterwards. OnLocalWrite is therefore the only mechanism that can close that window, which makes its missing caller in src a correctness gap rather than a nicety. Queued as such. Also: expiry produced no push at all, neither passively after the TTL nor when a later read forced the deletion. That is the case CachePolicy.TimeToLive exists to bound, and the argument now has evidence rather than only reasoning. --- design/interpolated-resp-writer.md | 37 ++++++++++++++++++++++++ design/interpolated-resp-writer.queue.md | 25 ++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index d2c9c798d..2f26dc4ce 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2031,6 +2031,43 @@ hand it to whoever happened to be at the front of the queue. Its three unreadabl not an aggregate, a streaming aggregate, a key whose bytes we cannot see contiguously) all **over-flush** rather than guess: we already know something changed, and the same judgement is made on disconnect. +#### When invalidations actually arrive, measured + +Against Redis 8.9.241, RESP3, `BCAST PREFIX`, reading raw bytes off one socket so the order on the wire +*is* the answer: + +| What was sent | What came back, in order | +| --- | --- | +| `SET k v` (self-tracked) | `+OK`, **then** `>2 invalidate [k]` | +| `MSET a b c` | `+OK`, then **one** push: `>2 invalidate [a, b, c]` | +| `SET k1` + `SET k2`, pipelined | `+OK`, `+OK`, then **one** push: `>2 invalidate [k1, k2]` | +| `SET k v` then `GET k`, pipelined | `+OK`, `$2 v`, **then** the invalidation | +| `DEL k` | `:1`, then `>2 invalidate [k]` | +| `FLUSHDB` | `>2 invalidate _` **then** `+OK` | +| `SET k v PX 100`, then wait, then read it | **nothing** - no push at all, before or after the expiry | + +Four things follow, and none of them were obvious. + +**Invalidations trail their replies, and are accumulated across the write cycle rather than emitted per +command.** Two pipelined `SET`s produced a single two-key push after *both* `+OK`s - so the batching unit +is not the command, it is whatever the server flushes in one go. + +**Which means a self-invalidation can never protect read-your-own-writes.** The `SET`/`GET` row is the +proof: the reply to a read issued *after* the write still precedes the notification about it. A cache +holding a stale entry for `k` would answer that `GET` from cache, and the correction arrives afterwards. +`RespClientCache.OnLocalWrite` is therefore not an optimisation or a latency shortcut - it is the *only* +mechanism that can close that window, because the server's own message is late by construction. It +currently has no caller in `src`, which makes wiring it a correctness item rather than a nicety. + +**`FLUSHDB` is the exception that a fake will get wrong.** Its `invalidate null` is emitted *before* its +own `+OK`, where every key invalidation comes after. So "accumulate and fan out after the reply" is right +for writes and wrong for flushes. + +**Expiry was not announced at all** - not passively after the TTL passed, and not even when a subsequent +read forced the deletion. Whatever the documented behaviour, an entry whose only end is expiry may get no +notification, which is the case `CachePolicy.TimeToLive` exists to bound. This is the evidence for that +argument, which until now rested on reasoning. + #### Where the cache lives A cache needs an owner before a push has anywhere to go, and until now there wasn't one: the cache was a diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index bebfd97f3..68e520237 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -25,6 +25,31 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Next +- [ ] **Wire `OnLocalWrite`** — it has no caller in `src`, and the timing measurements (6.13) make that a + **correctness** gap rather than a missing optimisation. A self-invalidation always trails its own + reply, and trails the replies of anything pipelined behind it: `SET k v` then `GET k` returns the + read *before* the notification about the write. So the server's message can never close the + read-your-own-writes window, and the local hook is the only thing that can. + +- [ ] **Teach the in-proc server `CLIENT TRACKING`** (`toys/StackExchange.Redis.Server`), for test + isolation: the cache suite currently needs a shared 6379, where one test's `FLUSHDB` reaches every + other test's tracking connection. Most of the seams already exist - `RespServer.Touch(db, key)` is + already a virtual broadcast to every client on every non-readonly key access, `node.OnOutOfBand` + already delivers pushes for pub/sub, `TypedRedisValue.Rent(n, out span, PushKind)` builds the frame, + and the writer already handles `RespPrefix.Push when value.IsNullArray`, which is the flush shape. + New: parse `CLIENT TRACKING ON|OFF [BCAST] [PREFIX p ...]` into per-client state, and fan out. + + **Timing is the part to get right, and it is measured rather than guessed (6.13).** Key + invalidations are *accumulated across the write cycle* and emitted **after** the replies - two + pipelined `SET`s produce one two-key push after both `+OK`s - so the fake needs an accumulator + flushed at the end of a batch, not a send inside `Touch`. `FLUSHDB` is the exception: its + `invalidate null` goes out **before** its own `+OK`. + + Per-key (non-`BCAST`) mode is nearly as cheap - `OnKey` already runs per key with a `ReadOnly` flag - + and is worth having because it is the mode whose "server forgets the key once it has told you" + behaviour the `NOLOOP` argument rests on. + + - [ ] **Get the arrays off the new API.** There should be very close to zero. Counted at the start: 32 array occurrences on the `SER010`/`SER011` surface, of which **29 are `ValueTask` returns** - `RedisValue[]`, `HashEntry[]`, `SortedSetEntry[]`, `double?[]`, `long[]`, `bool[]`, From b21ad97aa9342fd105b989ac518ee117163097d9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:13:35 +0100 Subject: [PATCH 143/360] A write tells the cache before it is sent OnLocalWrite had no caller in src, and the timing measurements make that a correctness gap rather than a missing optimisation: a write's own invalidation arrives after its reply, and after the replies of anything pipelined behind it, so SET k v followed by GET k returns the read before the notice about the write. Until now that read was answered from a stale entry and corrected afterwards - handing a caller back the value they just replaced, which is the one kind of staleness this design refuses. Stamped on send rather than on reply, because a read's dependencies are captured when it is sent: a stamp landing after our reply can be overtaken by a read issued in between, which would then validate and be stored. Stamping first widens the window, and a window that is too wide costs a miss. If the write then fails we invalidated for nothing, which is the right way to be wrong. Hooked outside the "may I cache this?" gate, because a write is exactly what that gate excludes - which is why nothing called it before. An undeclared retry category counts as a write, matching the judgement the caching side already makes from the other direction: undeclared cannot mean safe, so there it means "do not cache" and here "assume it wrote". A write whose keys cannot be enumerated flushes everything, because a write is precisely where guessing is not allowed. --- design/interpolated-resp-writer.queue.md | 15 +-- .../Interpolated/RespClientCache.cs | 48 +++++++ .../Interpolated/RespExecutor.cs | 41 ++++++ .../RespClientCacheTests.cs | 125 ++++++++++++++++++ 4 files changed, 221 insertions(+), 8 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 68e520237..fd4173aa5 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -25,12 +25,6 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Next -- [ ] **Wire `OnLocalWrite`** — it has no caller in `src`, and the timing measurements (6.13) make that a - **correctness** gap rather than a missing optimisation. A self-invalidation always trails its own - reply, and trails the replies of anything pipelined behind it: `SET k v` then `GET k` returns the - read *before* the notification about the write. So the server's message can never close the - read-your-own-writes window, and the local hook is the only thing that can. - - [ ] **Teach the in-proc server `CLIENT TRACKING`** (`toys/StackExchange.Redis.Server`), for test isolation: the cache suite currently needs a shared 6379, where one test's `FLUSHDB` reaches every other test's tracking connection. Most of the seams already exist - `RespServer.Touch(db, key)` is @@ -43,7 +37,10 @@ a line saying why, because "we decided not to" is worth as much as "we did". invalidations are *accumulated across the write cycle* and emitted **after** the replies - two pipelined `SET`s produce one two-key push after both `+OK`s - so the fake needs an accumulator flushed at the end of a batch, not a send inside `Touch`. `FLUSHDB` is the exception: its - `invalidate null` goes out **before** its own `+OK`. + `invalidate null` goes out **before** its own `+OK` - though the fake may emit it after, and that + is a deliberate, recorded divergence rather than an oversight: the ordering only matters to the client + doing the flushing, which has already called `OnFlush` locally, and everyone else receives it + unsolicited where ordering means nothing. Per-key (non-`BCAST`) mode is nearly as cheap - `OnKey` already runs per key with a `ReadOnly` flag - and is worth having because it is the mode whose "server forgets the key once it has told you" @@ -212,7 +209,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Split `CacheOptions` (settled once: prefixes, budget) from `CachePolicy` (read-time, per-call) — `286a461a` - [x] `Execute` -> `Render` on the context: rendering is not executing — `682cc687` - [x] Wipe pooled arrays whose elements can hold references — `2c905046` -- [x] MGET returns a pooled lease; the array shape moves to an internal sibling — this change +- [x] MGET returns a pooled lease; the array shape moves to an internal sibling — `ae3abb1e` +- [x] Measured invalidation timing against a real server (6.13) — `eddb3b5f` +- [x] Wire `OnLocalWrite`: a write tells the cache before it is sent — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` - [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 97f3641fc..5ba03b326 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -251,6 +251,54 @@ public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) /// public bool OnLocalWrite(ReadOnlySpan key) => _keys.Invalidate(key, local: true, Policy.ServesStale); + /// + /// Note that this process is about to send a command that changes keys, and invalidate every + /// key it names. + /// + /// The rendered command, read for its keys. + /// The number of keys stamped. + /// + /// + /// Before the send, not after the reply, and that is the whole point. A read's dependencies + /// are captured when it is sent, so a stamp that lands after our reply can be overtaken by a read + /// issued in between - which would then be considered valid, and stored. Stamping first widens the + /// window; the cost of a window that is too wide is a miss. + /// + /// + /// If the write then fails, we invalidated for nothing. That is the right way to be wrong. + /// + /// + /// A frame that cannot enumerate its keys invalidates everything. TryGetKeys returning + /// -1 means "I have keys but cannot tell you which" - and a write whose keys we cannot name is + /// precisely the case where guessing is not allowed. + /// + /// + internal int OnLocalWrite(in RespRequest frame) + { + var keyCount = frame.KeyCount; + if (keyCount == 0) return 0; + if (keyCount < 0) + { + OnFlush(); + return -1; + } + + Span ranges = keyCount <= 16 ? stackalloc KeyRange[16] : new KeyRange[keyCount]; + var count = frame.TryGetKeys(ranges); + if (count < 0) + { + OnFlush(); + return -1; + } + + for (var i = 0; i < count; i++) + { + OnLocalWrite(frame.GetKey(ranges[i])); + } + + return count; + } + /// /// Invalidate everything - a null invalidation (FLUSHALL/FLUSHDB), a lost connection, /// or tracking-redir-broken. diff --git a/src/StackExchange.Redis/Interpolated/RespExecutor.cs b/src/StackExchange.Redis/Interpolated/RespExecutor.cs index c96c6fefe..18c00f5d1 100644 --- a/src/StackExchange.Redis/Interpolated/RespExecutor.cs +++ b/src/StackExchange.Redis/Interpolated/RespExecutor.cs @@ -122,6 +122,45 @@ public static class RespExecutor /// which says nothing about fire-and-forget to whoever has to read it. /// /// + /// + /// Whether this command changes keys, so far as its flags admit. + /// + /// + /// The retry category is a severity ladder; anything past + /// writes. An undeclared category counts as a + /// write too, which is the same judgement the caching side makes from the other direction: + /// undeclared cannot mean safe, so there it means "do not cache" and here it means "assume it + /// wrote". Both err towards a miss. + /// + private static bool Mutates(CommandFlags flags) + { + var category = flags & Message.MaskRetryCategory; + return category == 0 || category > CommandFlags.CommandRetryReadOnly; + } + + /// + /// Tell the cache about a write of ours before it goes out, so a read cannot slip between the + /// send and the server's echo of it. + /// + /// + /// + /// Server-assisted invalidation cannot do this job. Measured against a real server, a write's own + /// invalidation arrives after its reply - and after the replies of anything pipelined behind + /// it - so SET k v followed by GET k returns the read before the notice about the + /// write. Without this, that read is answered from a stale entry and corrected afterwards, which is + /// the one kind of staleness this design refuses: handing a caller back the value they just + /// replaced. See design notes 6.13. + /// + /// + /// Outside the "may I cache this?" gate, because a write is exactly what that gate excludes - which + /// is why nothing called OnLocalWrite until now. + /// + /// + private static void NoteLocalWrite(RespClientCache? cache, in RespFrame request, CommandFlags flags) + { + if (cache is not null && Mutates(flags)) cache.OnLocalWrite(request.AsLookupKey()); + } + private static TResult Parse(IRespHandler handler, RespPayload? response) => response switch { @@ -167,6 +206,7 @@ public static TResult Send( if (handler is null) throw new ArgumentNullException(nameof(handler)); var executor = context.Executor ?? ThrowNoExecutor(ref request); var cache = context.Cache; + NoteLocalWrite(cache, in request, flags); // NoClientCache suppresses the PROBE as well as the store: opting out must mean the caller does // not get a cached answer either, not merely that this reply is not kept @@ -259,6 +299,7 @@ public static ValueTask SendAsync( var executor = context.Executor ?? ThrowNoExecutor(ref request); var cache = context.Cache; var cancellationToken = context.CancellationToken; + NoteLocalWrite(cache, in request, flags); if (cache is not null && cache.PermitsCaching(flags)) { diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index fc8c5621c..6d446fb0a 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1403,4 +1403,129 @@ public void NonPositiveBudgetsAreRejected() Assert.Null(CacheOptions.Default.MaxEntries); } + /// + /// A write of ours evicts what we had cached for that key, without waiting for the server to say so. + /// + /// + /// The window this closes is real and measured: a write's own invalidation arrives after its reply, and + /// after the replies of anything pipelined behind it, so the server can never tell us in time. Without + /// this the read below is answered from cache with the value we just replaced - which is not staleness + /// a caller can shrug at, it is a wrong answer. + /// + [Fact] + public async Task OurOwnWriteEvictsWhatWeCached() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$2\r\nv1\r\n"); + var context = Via(executor, cache); + + var read = Get("abc"); + Assert.Equal("$2|v1|", await context.SendAsync(ref read, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + Assert.Equal(1, executor.Sent); + + // cached: a second read does not reach the executor + var again = Get("abc"); + Assert.Equal("$2|v1|", await context.SendAsync(ref again, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + Assert.Equal(1, executor.Sent); + + // now WE write it - no server invalidation involved anywhere in this test + var write = Ctx.Render($"{RedisCommand.SET}{(RedisKey)"abc"}{(RedisValue)"v2"}"); + await context.SendAsync(ref write, CommandFlags.CommandRetryWriteLastWins, TextHandler.Instance); + + // ...and the next read must go and ask, rather than hand back what we just replaced + var after = Get("abc"); + Assert.Equal("$2|v1|", await context.SendAsync(ref after, CommandFlags.CommandRetryReadOnly, TextHandler.Instance)); + Assert.Equal(3, executor.Sent); // read, write, re-read + } + + /// A read of ours does not invalidate anything. + /// + /// The other half: if the mutation test were simply "anything that is not cacheable invalidates", a + /// fire-and-forget read or a NoClientCache read would evict the very entry it declined to use. + /// + [Theory] + [InlineData(CommandFlags.CommandRetryReadOnly)] + [InlineData(CommandFlags.CommandRetryReadOnly | CommandFlags.FireAndForget)] + [InlineData(CommandFlags.CommandRetryReadOnly | CommandFlags.NoClientCache)] + public async Task ReadsDoNotInvalidate(CommandFlags readFlags) + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$2\r\nv1\r\n"); + var context = Via(executor, cache); + + var read = Get("abc"); + await context.SendAsync(ref read, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); + Assert.Equal(1, cache.Count); + + var other = Get("abc"); + await context.SendAsync(ref other, readFlags, TextHandler.Instance); + + // still cached, and still servable + using var probe = Get("abc"); + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var hit)); + hit.Release(); + } + + /// + /// An undeclared retry category is treated as a write. + /// + /// + /// The same judgement the caching side makes from the other direction. Undeclared cannot mean safe: for + /// storing it means "do not", and for invalidating it means "assume it wrote". An ad-hoc command + /// through ExecuteAsync with no category is exactly this case. + /// + [Fact] + public async Task AnUndeclaredCategoryIsAssumedToWrite() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$2\r\nv1\r\n"); + var context = Via(executor, cache); + + var read = Get("abc"); + await context.SendAsync(ref read, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); + Assert.Equal(1, cache.Count); + + var unknown = Ctx.Render($"{RedisCommand.SET}{(RedisKey)"abc"}{(RedisValue)"v2"}"); + await context.SendAsync(ref unknown, CommandFlags.None, TextHandler.Instance); + + using var probe = Get("abc"); + Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _), "an undeclared command should be assumed to write"); + } + + /// A write we cannot enumerate the keys of invalidates everything. + /// + /// TryGetKeys returning -1 means "there are keys, but I cannot tell you which". A write is + /// precisely where guessing is not allowed, so the whole cache goes - over-flushing costs round trips, + /// under-flushing costs correctness. + /// + [Fact] + public async Task AWriteWithUnknowableKeysFlushesEverything() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$2\r\nv1\r\n"); + var context = Via(executor, cache); + + foreach (var key in new[] { "a", "b", "c" }) + { + var read = Get(key); + await context.SendAsync(ref read, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); + } + + Assert.Equal(3, cache.Count); + + // more keys than the frame can mark individually: KeyCount goes negative, meaning "not enumerable" + var many = Ctx.Compose($"{RedisCommand.MSET}"); + for (var i = 0; i < 80; i++) many.Append($"{(RedisKey)("k" + i)}{(RedisValue)"v"}"); + var frame = Ctx.Render(ref many); + Assert.True(frame.KeyCount < 0, $"expected unknowable keys, got KeyCount={frame.KeyCount}"); + + await context.SendAsync(ref frame, CommandFlags.CommandRetryWriteLastWins, TextHandler.Instance); + + foreach (var key in new[] { "a", "b", "c" }) + { + using var probe = Get(key); + Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _), $"'{key}' should have gone with the flush"); + } + } + } From e501a1cb87922b2c022b0acfba421e460e0dae8c Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:28:46 +0100 Subject: [PATCH 144/360] Record why HIMPORT is a holdout, and why EVALSHA is not The previous note said HashImport "needs a connection-local PREPARE injected ahead of it, which is a property of the write path rather than of the command". True, but it points at the implementation rather than the constraint, and it invited the wrong comparison. SELECT is the tempting sibling, because it shares the injection mechanism - and it is a red herring. A database index is a register the client mirrors and is the sole author of, so it can never miss. EVALSHA is the real sibling: an optimistic short reference to a named, cached payload, where the client's belief that the payload is present can be wrong. HIMPORT SET is the same shape. The comparison earns its keep by breaking in one specific place. EVALSHA has a self-contained fallback - EVAL carries the whole script - so a NOSCRIPT is recovered by re-rendering a single message, which is why ResultProcessor deliberately keeps the request buffer alive on that one error. HIMPORT SET has no such form. HSET is NOT it: HIMPORT replaces the hash at the key where HSET merges into it (pinned by ExistingHashIsReplacedNotMerged), so the inline expansion is DEL plus HSET - two commands, not atomic, different failure profile. Recovery is therefore inherently two ordered commands on one socket. Which makes the current design better than the old note implied. Injecting inside the write lock is not a workaround for a missing preamble concept: it is the only point at which the connection is known and nothing has been written, which is exactly the window that recovery needs - and acting there is what lets a field-set avoid pinning a connection at all. A frame surface has no such point, so expressing this outside the bridge would need CONNECTION affinity across a retry, where CommandServerSpecific pins an endpoint. So: EVALSHA is portable to the frame surface (it needs the frame to carry an alternate rendering - real work, but nothing from the connection); HIMPORT is not, until something can say "this retry, that socket". --- .../APITypes/HashImport.cs | 37 ++++++++++++++++++- .../TransitionalDatabase.Hashes.cs | 6 +-- .../TransitionalSurfaceTests.cs | 4 +- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/StackExchange.Redis/APITypes/HashImport.cs b/src/StackExchange.Redis/APITypes/HashImport.cs index 92e6f91f1..e30eae8f7 100644 --- a/src/StackExchange.Redis/APITypes/HashImport.cs +++ b/src/StackExchange.Redis/APITypes/HashImport.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -28,6 +28,41 @@ namespace StackExchange.Redis; /// hygiene for long-lived connections. /// /// A single is safe to use concurrently and against multiple databases/multiplexers. +/// +/// Why this cannot currently move to the interpolated command surface. Every other command moved so far +/// is a pure function from arguments to bytes; HIMPORT SET is not, because it references state that must +/// already exist on the socket it lands on. The nearest sibling is EVALSHA, which has the same shape - +/// an optimistic short reference to a named, cached payload, where the client's belief that the payload is +/// present can be wrong - and the comparison is instructive precisely because of where it breaks: +/// +/// +/// +/// Scope. The script cache is server-wide, so an EVALSHA recovery can be routed like any other +/// command. A field-set is connection-local, so a recovery must reach one specific socket. +/// +/// +/// Fallback form. EVAL <script> is a single self-contained command carrying everything the +/// hash referenced, so a NOSCRIPT is recovered by re-rendering one message (see +/// ResultProcessor.RespResult, which keeps the request buffer alive for exactly that). HIMPORT SET +/// has no such form. HSET is not it: this command replaces the hash at the key, where +/// HSET merges into it, so the inline expansion would be DEL plus HSET - two commands, +/// not atomic, and a different failure profile. Recovery is therefore inherently two ordered commands that +/// must share a connection. +/// +/// +/// +/// Which is why the PREPARE is injected inside the bridge's write lock rather than anywhere earlier: +/// that is the only point at which the connection is known and nothing has been written yet, and it is exactly +/// the window a two-command, connection-local recovery needs. Acting there is what lets this type avoid pinning +/// a connection at all. A frame-based surface has no such point - it hands an opaque payload to an executor and +/// the connection is chosen afterwards - so expressing this outside the bridge would need connection +/// affinity across a retry, and CommandServerSpecific pins an endpoint, not a connection. +/// +/// +/// Note the comparison with SELECT injection is a red herring, tempting though the shared mechanism is: +/// a database index is a register the client mirrors and is the sole author of, so it can never miss. This and +/// EVALSHA are lookups by name that can. +/// /// [Experimental(Experiments.Server_8_10, UrlFormat = Experiments.UrlFormat)] public sealed class HashImport : IDisposable, IAsyncDisposable diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs index 147233b48..d12bc7107 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs @@ -15,9 +15,9 @@ namespace StackExchange.Redis.Interpolated /// persist/keepTtl parameters were spelling out between them. /// /// - /// HashScan stays in TransitionalDatabase.Scans.cs, and HashImport stays with the - /// generated members: it needs a connection-local PREPARE injected ahead of it, which is a - /// property of the write path rather than of the command, and the frame path has no way to say it. + /// HashScan stays in TransitionalDatabase.Scans.cs. HashImport stays with the + /// generated members, and the reason is worth stating precisely because the obvious version of it is + /// wrong: see . /// /// internal sealed partial class TransitionalDatabase diff --git a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs index d7725b511..c2e0addf4 100644 --- a/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs +++ b/tests/StackExchange.Redis.Tests/TransitionalSurfaceTests.cs @@ -144,7 +144,9 @@ public void EveryMemberOfAMovedGroupIsImplemented(string prefix) // the ones deliberately left behind, each for a reason that is not "not done yet": // - StringGetWithExpiry pipelines TTL+GET, and a composite is not a frame - // - HashImport needs a connection-local PREPARE injected ahead of it + // - HashImport references connection-local state and has no self-contained fallback, so its + // recovery is two ordered commands on one socket; see the remarks on HashImport, which also + // record why EVALSHA - the same shape - escapes this and HIMPORT cannot // - the scans are deferred-execution cursors var expected = generated .Where(x => !x.StartsWith("StringGetWithExpiry", StringComparison.Ordinal)) From bbb91af628c59cafce548e8bd1e51d1217ad97a9 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:33:31 +0100 Subject: [PATCH 145/360] A bulk write invalidates its own arguments, not the whole cache The first version of the local-write hook answered "key marks overflowed" with OnFlush. Correct, and far too blunt: a frame can only mark keys up to argument 62, so MSET or DEL past that would destroy an unrelated hot cache every time - and bulk writes are exactly the workload that would suffer. Reporting a subset of the keys is forbidden, which is why the frame refuses to report one at all. But the arguments are a SUPERSET of the keys, so stamping all of them is sound and stays inside the command. The cost is one needless miss for any value that happens to equal a cached key, on a command that already named enough keys to overflow the bitmap. Only writes reach this. A 100-key MGET is a read, so it never touches the local-write path; it is simply not cached, by the same keyless rule, because a frame that cannot name its keys cannot be invalidated either. --- design/interpolated-resp-writer.md | 12 +++++ .../Interpolated/RespClientCache.cs | 27 ++++++---- .../Interpolated/RespFrame.cs | 52 +++++++++++++++++++ .../Interpolated/RespRequest.cs | 4 ++ .../RespClientCacheTests.cs | 52 ++++++++++++++++--- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/design/interpolated-resp-writer.md b/design/interpolated-resp-writer.md index 2f26dc4ce..6a8bfbda3 100644 --- a/design/interpolated-resp-writer.md +++ b/design/interpolated-resp-writer.md @@ -2063,6 +2063,18 @@ currently has no caller in `src`, which makes wiring it a correctness item rathe own `+OK`, where every key invalidation comes after. So "accumulate and fan out after the reply" is right for writes and wrong for flushes. +**A write whose key marks overflow stamps its arguments, not the whole cache.** A frame can only mark keys +up to argument 62, so a large `MSET` or `DEL` reports "I have keys but cannot tell you which" - and the +first version of the local-write hook answered that with `OnFlush`. That is correct and far too blunt: a +bulk write would destroy an unrelated hot cache every time. Reporting a *subset* is forbidden - it is why +the frame refuses to report one at all - but the arguments are a **superset** of the keys, and stamping a +superset is sound. The cost is one needless miss for any value that happens to equal a cached key, on a +command that already named enough keys to overflow the bitmap. + +Note this only ever applies to **writes**. A 100-key `MGET` is a read, so it never reaches this path; it is +simply not cached, by the same keyless rule, because a frame that cannot name its keys cannot be +invalidated either. + **Expiry was not announced at all** - not passively after the TTL passed, and not even when a subsequent read forced the deletion. Whatever the documented behaviour, an entry whose only end is expiry may get no notification, which is the case `CachePolicy.TimeToLive` exists to bound. This is the evidence for that diff --git a/src/StackExchange.Redis/Interpolated/RespClientCache.cs b/src/StackExchange.Redis/Interpolated/RespClientCache.cs index 5ba03b326..56375ad8d 100644 --- a/src/StackExchange.Redis/Interpolated/RespClientCache.cs +++ b/src/StackExchange.Redis/Interpolated/RespClientCache.cs @@ -268,25 +268,32 @@ public RespClientCache(CacheOptions? options = null, int keyCapacity = 256) /// If the write then fails, we invalidated for nothing. That is the right way to be wrong. /// /// - /// A frame that cannot enumerate its keys invalidates everything. TryGetKeys returning - /// -1 means "I have keys but cannot tell you which" - and a write whose keys we cannot name is - /// precisely the case where guessing is not allowed. + /// A write whose key marks overflowed stamps every argument instead of flushing. A frame can + /// only mark keys up to argument 62, so a large MSET or DEL reports "I have keys but + /// cannot tell you which". Guessing a subset is not allowed - that is the whole reason the frame + /// refuses to report one - but the arguments are a superset of the keys, and stamping a + /// superset is correct. It costs one needless miss for any value that happens to equal a cached + /// key, and it keeps the damage inside the command instead of taking out the entire cache, which is + /// what this used to do. Bulk writes are exactly the workload that would have suffered. + /// + /// + /// Only a frame that cannot be read at all falls back to . /// /// internal int OnLocalWrite(in RespRequest frame) { var keyCount = frame.KeyCount; if (keyCount == 0) return 0; - if (keyCount < 0) - { - OnFlush(); - return -1; - } - Span ranges = keyCount <= 16 ? stackalloc KeyRange[16] : new KeyRange[keyCount]; - var count = frame.TryGetKeys(ranges); + // negative means the marks overflowed; the arguments are a superset of the keys, and there are + // at most ArgCount of them + var wanted = keyCount < 0 ? frame.ArgCount : keyCount; + Span ranges = wanted <= 16 ? stackalloc KeyRange[16] : new KeyRange[wanted]; + + var count = keyCount < 0 ? frame.TryGetAllArguments(ranges) : frame.TryGetKeys(ranges); if (count < 0) { + // unreadable rather than merely unmarked; nothing left but the blunt instrument OnFlush(); return -1; } diff --git a/src/StackExchange.Redis/Interpolated/RespFrame.cs b/src/StackExchange.Redis/Interpolated/RespFrame.cs index 57d915a8b..e610c99ce 100644 --- a/src/StackExchange.Redis/Interpolated/RespFrame.cs +++ b/src/StackExchange.Redis/Interpolated/RespFrame.cs @@ -199,6 +199,58 @@ private static int WalkKeys(byte[] buffer, int start, int length, ulong bitmap, return count; } + /// + /// Every argument of the command, whether or not it was marked as a key. + /// + /// The number written, or -1 if is too small. + /// + /// + /// For the one caller that needs a superset of the keys rather than the keys: invalidating + /// after a write whose key marks were truncated. The true key set is always a subset of the + /// arguments, so stamping all of them is correct, and it is bounded by the command instead of + /// taking out the whole cache. + /// + /// + /// The cost of the over-approximation is that a value which happens to equal some cached key + /// gets invalidated too - one needless miss, on a command that already named enough keys to + /// overflow the bitmap. That is a far better trade than flushing everything. + /// + /// + /// Argument 0 is skipped: it is the command name, never a key. + /// + /// + internal static int ResolveAllArguments(byte[] buffer, int start, int length, scoped Span target) + { + var end = start + length; + var i = start; + while (buffer[i] != (byte)'\n') i++; // past the '*N\r\n' header + i++; + + int arg = 0, count = 0; + while (i < end) + { + var j = i + 1; // past the '$' + var bulk = 0; + while (buffer[j] != (byte)'\r') + { + bulk = (bulk * 10) + (buffer[j] - (byte)'0'); + j++; + } + + var payload = j + 2; + if (arg != 0) + { + if (count >= target.Length) return -1; + target[count++] = new KeyRange(payload, bulk); + } + + i = payload + bulk + 2; + arg++; + } + + return count; + } + /// Resolve a against the underlying buffer. public readonly ReadOnlySpan GetKey(in KeyRange range) => new(_buffer, range.Offset, range.Length); diff --git a/src/StackExchange.Redis/Interpolated/RespRequest.cs b/src/StackExchange.Redis/Interpolated/RespRequest.cs index 262a13a73..9097428a0 100644 --- a/src/StackExchange.Redis/Interpolated/RespRequest.cs +++ b/src/StackExchange.Redis/Interpolated/RespRequest.cs @@ -95,6 +95,10 @@ internal RespRequest( public int TryGetKeys(scoped Span target) => _array is null ? -1 : RespFrame.ResolveKeys(_array, _offset, _length, _keyMarks, target); + /// Every argument, whether or not marked as a key; see . + internal int TryGetAllArguments(scoped Span target) + => _array is null ? -1 : RespFrame.ResolveAllArguments(_array, _offset, _length, target); + /// Resolve a against the underlying buffer. /// The range to resolve. public ReadOnlySpan GetKey(in KeyRange range) diff --git a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs index 6d446fb0a..a49ff99a0 100644 --- a/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs +++ b/tests/StackExchange.Redis.Tests/RespClientCacheTests.cs @@ -1492,14 +1492,22 @@ public async Task AnUndeclaredCategoryIsAssumedToWrite() Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _), "an undeclared command should be assumed to write"); } - /// A write we cannot enumerate the keys of invalidates everything. + /// + /// A write whose key marks overflowed invalidates its own keys - and not the rest of the cache. + /// /// - /// TryGetKeys returning -1 means "there are keys, but I cannot tell you which". A write is - /// precisely where guessing is not allowed, so the whole cache goes - over-flushing costs round trips, - /// under-flushing costs correctness. + /// + /// A frame can only mark keys up to argument 62, so a large MSET or DEL cannot say which + /// of its arguments were keys. Reporting a subset is forbidden, but the arguments are a superset + /// of the keys, so stamping all of them is correct and stays inside the command. + /// + /// + /// The first version of this flushed the whole cache, which would have made a bulk write destroy an + /// unrelated hot cache every time - the exact question that prompted looking again. + /// /// [Fact] - public async Task AWriteWithUnknowableKeysFlushesEverything() + public async Task AWriteWithUnknowableKeysInvalidatesOnlyItsOwn() { using var cache = new RespClientCache(); var executor = new FakeExecutor("$2\r\nv1\r\n"); @@ -1521,11 +1529,43 @@ public async Task AWriteWithUnknowableKeysFlushesEverything() await context.SendAsync(ref frame, CommandFlags.CommandRetryWriteLastWins, TextHandler.Instance); + // the unrelated entries survive: this write never mentioned them foreach (var key in new[] { "a", "b", "c" }) { using var probe = Get(key); - Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _), $"'{key}' should have gone with the flush"); + Assert.True(cache.TryGet(probe.AsLookupKey(), 0, out var alive), $"'{key}' should not have been touched"); + alive.Release(); } } + /// ...and the keys such a write DID name are invalidated, marks or no marks. + /// + /// The other half of the superset argument: stamping the arguments is only acceptable because it is a + /// superset. If it missed a key past the bitmap, the write would go unannounced locally and the entry + /// would be served until the server's echo caught up - which is the window this whole mechanism exists + /// to close. + /// + [Fact] + public async Task AWriteWithUnknowableKeysStillInvalidatesTheKeysItNamed() + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor("$2\r\nv1\r\n"); + var context = Via(executor, cache); + + // k70 sits well past the bitmap's last markable argument + var read = Get("k70"); + await context.SendAsync(ref read, CommandFlags.CommandRetryReadOnly, TextHandler.Instance); + Assert.Equal(1, cache.Count); + + var many = Ctx.Compose($"{RedisCommand.MSET}"); + for (var i = 0; i < 80; i++) many.Append($"{(RedisKey)("k" + i)}{(RedisValue)"v"}"); + var frame = Ctx.Render(ref many); + Assert.True(frame.KeyCount < 0); + + await context.SendAsync(ref frame, CommandFlags.CommandRetryWriteLastWins, TextHandler.Instance); + + using var probe = Get("k70"); + Assert.False(cache.TryGet(probe.AsLookupKey(), 0, out _), "a key past the bitmap must still be invalidated"); + } + } From 9625bde164e33fb6de576c36899ee78bff6425fc Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:54:01 +0100 Subject: [PATCH 146/360] Arrays off the new surface: 28 returns become ReadOnlyLease Every ValueTask on the experimental surface is now a pooled lease the caller gives back; the array shape moves to internal ...Array siblings that feed the old IDatabase signatures, and RespHandlers.Values is demoted to internal alongside them. What is left is two RespAttribute members, where the CLR requires arrays. Siblings rather than conversions: IDatabase promises an array the caller owns, so bridging through a lease would rent a pooled buffer only to copy out of it. The pair-shaped handlers cost nothing extra: ParseArray(allowOversized: true) already rents from ArrayPool and reports the live length separately, which is a lease wearing different clothes, so ReadOnlyLease.Adopt takes the rental as-is. Method-boundary detection is by paren/brace depth rather than "to the first semicolon" - the first attempt cut block-bodied methods in half, which the build caught immediately. --- design/interpolated-resp-writer.queue.md | 112 +++------ .../Interpolated/RespSurface.Hashes.cs | 174 +++++++++++++- .../Interpolated/RespSurface.Sets.cs | 68 +++++- .../Interpolated/RespSurface.SortedSets.cs | 209 ++++++++++++++++- .../Interpolated/RespSurface.cs | 89 ++++++- .../TransitionalDatabase.Hashes.cs | 56 ++--- .../Interpolated/TransitionalDatabase.Sets.cs | 24 +- .../TransitionalDatabase.SortedSets.cs | 44 ++-- .../PublicAPI/PublicAPI.Unshipped.txt | 219 +++++++++--------- src/StackExchange.Redis/ReadOnlyLease.cs | 14 ++ .../RespSurfaceHashesTests.cs | 12 +- .../RespSurfaceSetsTests.cs | 6 +- .../RespSurfaceSortedSetsTests.cs | 4 +- 13 files changed, 734 insertions(+), 297 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index fd4173aa5..6261d350d 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -47,90 +47,30 @@ a line saying why, because "we decided not to" is worth as much as "we did". behaviour the `NOLOOP` argument rests on. -- [ ] **Get the arrays off the new API.** There should be very close to zero. Counted at the start: 32 array - occurrences on the `SER010`/`SER011` surface, of which **29 are `ValueTask` returns** - - `RedisValue[]`, `HashEntry[]`, `SortedSetEntry[]`, `double?[]`, `long[]`, `bool[]`, - `ExpireResult[]`, `PersistResult[]`. Inherited wholesale from the old surface, where there was no - alternative; here there is. - The *inputs* were already done right - `ReadOnlySpan` throughout - so this is one-sided, and it is - the side that allocates per call with nothing able to reclaim it. - - **Why now and not later.** An array return is a binary-compat trap of its own: `T[]` can never - become anything else without a break, so the experimental window is the only chance. And the cost - grows with every command group added - each new group written in the old shape is more to undo, and - groups are being added right now. - - **The shape.** A return cannot be a span, because these are all `async`; it has to be something that - carries a count and can be given back. `ReadOnlyLease` already exists for exactly this reason and - already solved the hard part (see 6.16 - `Release()` is a bare decrement, which is why it is a class - and not a struct). The value-type element arrays are the sweetest: `double?[]`, `long[]`, `bool[]`, - `ExpireResult[]`, `PersistResult[]` pool with *no* element allocation at all. For `RedisValue[]` the - lease saves the array and not the elements - `RedisValue` has no lifetime, which is settled and not - to be relitigated - but on a large `MGET` the array is the part that lands in gen-2. - - **The honest cost:** a lease must be disposed and an array need not be, so this trades forgiveness - for reclaim. That trade is already made elsewhere in this design (`RespResult`, `ReadOnlyLease`), - so the inconsistency today is that these were left behind, not that changing them is novel. - - **The one real exception:** `RespAttribute` - `params string[]` and `Tokens`. Attribute arguments - must be arrays; the CLR gives no choice. Worth stating so it is not "fixed" by someone later. - - **Worked example landed:** `Strings.Get(keys)` (MGET) now returns `ReadOnlyLease`, with - an internal `GetArray` sibling for `TransitionalDatabase`, and `RespHandlers.ValueLease` beside - `RespHandlers.Values`. 28 array returns left, all the same transformation. Prerequisite found and - fixed on the way: `ReadOnlyLease` was returning pooled arrays unwiped, which is right for `byte` - and retention for any `T` holding a reference. - - It does not need `Parse(ref RespReader)` after all - a handler can fill a rented span from a span - reply perfectly well - but the two still compose, and doing the remaining groups after that lands - would avoid touching each handler twice. - - **Satisfying the old API, which still says `T[]`.** `TransitionalDatabase` has to keep returning - arrays, so something has to bridge. The obvious move - give `ReadOnlyLease` an internal "hand me - your buffer" escape hatch - **does not work**, and it is worth saying why before someone tries it: - `Rent` goes to `ArrayPool.Shared`, which returns an *oversized* array, while the old contract - promises an exactly-sized one the caller owns. The steal could essentially never fire. So the variant - is not a method on the lease; it is a question about how the result is *built*, which is a question - about the handler. - - Shape: **a parallel internal extension method on the typed context**, sitting beside the public one, - sharing the message construction and differing only in the handler: - - ```csharp - public static ValueTask> Get(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) - => strings.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, RespHandlers.ValueLease); - - internal static ValueTask GetArray(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) - => strings.Context.SendAsync($"{RedisCommand.MGET}{keys}", flags, RespHandlers.Values); - ``` - - Three things this buys over a generic `GetCore(..., IRespHandler)` helper. It stays in the - classic `this` extension form, so the legacy sibling retires the way everything else on this surface - does. Being **internal**, it never appears on the public API, so it adds no array site to fix later. - And `TransitionalDatabase` stays a genuine one-line pass-through - `context.Strings.GetArray(...)` - - which was the whole point of that class. - - Sharing the construction is available now that `Render` exists: it is exactly the primitive both - siblings need, since each call wants its own frame and what is shared is the *composition*, not the - frame. Duplicating the interpolated line instead is one line and no knowledge, so either is fine. - - `RespHandlers.Values` (`IRespHandler`) already exists and is one of the three non-return - array sites: it is not deleted, it is demoted - off the public surface, onto the legacy sibling. - - Rejected, and recorded so nobody builds it: an internal "hand me your buffer" hatch on - `ReadOnlyLease`. `Rent` goes to `ArrayPool.Shared`, which returns an *oversized* array, while - the old contract promises an exactly-sized one the caller owns - so the steal could essentially never - fire, and what is left is `ToArray()` wearing a disguise. It would also put an ownership ambiguity - into the one type whose entire point is that ownership is unambiguous. - - `ToArray()` stays public on the lease regardless - that is the escape hatch for *callers* who want an - array, and it copies, honestly and visibly. - - -- [ ] **`Parse(ref RespReader)`** (§2.2, §6.16). Smaller prize than it looked once the outgoing-copy rule - landed — the sharing argument moved to `ReadOnlyLease` — so it is back to being about - **composability**: `IRespHandler` built from `IRespHandler`. Cheapest while handlers live in - one file. Mechanical: delete two lines per handler, take the parameter. +- [ ] **`Parse(ref RespReader)`, and the row-parser collapse** (§2.2, §6.16). Now with evidence rather + than a hunch: converting the arrays produced **16 handlers that are all "aggregate of X"** - eight + array, eight lease - of which six differ only by a one-line projection, which is why they were + factored onto a shared `ReadScalarLease`. With `Parse(ref RespReader)` the row parser *is* the scalar + handler: `IRespHandler` for `INCR` does `ReadInt64()`, and so does the row parser for + `ReadOnlyLease`. `double?` is character-for-character identical. So registration becomes + **implement the element handler, get the aggregate/lease/array for free** - the natural extension of + "registration is implementing the interface", and the story for module types. + + **Jagged vs interleaved belongs in the walker, not the row parser.** `HashEntry`/`SortedSetEntry` + look like exceptions because a row is two interleaved elements in RESP2 and one nested array in + RESP3 - but the old parser already hides that from its implementers entirely: `ParseArray` decides + `isJagged` once per reply and runs one of two loops that both call the same `Parse(ref first, ref + second, state)`. So the generic walker normalises, the row parser declares an **arity** (1 for + scalars, 2 for pairs), and all eight collapse rather than six. Carry the escape hatch across too: + `AllowJaggedPairs` is `protected virtual`, and `RedisStreamInterleavedProcessor` overrides it to + false because it works on an already-flattened map. + + The shape is endemic - `HGETALL`, `ZRANGE WITHSCORES`, `XRANGE`, `CONFIG GET` - so hoisting it once + pays on every group added, and leaving it per-handler means re-deriving the jagged check each time. + + A refactor that **deletes** code. Deliberately sequenced after the signature change: the public + shapes were binary-breaking and time-limited, the handler internals are internal and can be + collapsed whenever without touching a caller. - [ ] **`CLIENT TRACKING` negotiation in the real client.** RESP3-only; the mode and prefixes now come from `CacheOptions.TrackingMode` / `CacheOptions.Prefixes`, which are already validated against each @@ -211,7 +151,9 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Wipe pooled arrays whose elements can hold references — `2c905046` - [x] MGET returns a pooled lease; the array shape moves to an internal sibling — `ae3abb1e` - [x] Measured invalidation timing against a real server (6.13) — `eddb3b5f` -- [x] Wire `OnLocalWrite`: a write tells the cache before it is sent — this change +- [x] Wire `OnLocalWrite`: a write tells the cache before it is sent — `b21ad97a` +- [x] A bulk write invalidates its own arguments, not the whole cache — `bbb91af6` +- [x] Arrays off the new API: 28 returns become `ReadOnlyLease`, with internal `...Array` siblings — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` - [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs index 8c61507d9..5abbfef03 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -69,7 +69,19 @@ public static ValueTask Get(this in RespHashes hashes, RedisKey key, /// No fields means no command, as elsewhere: an arity-zero HMGET is a server error, and the /// values of no fields is an empty array without asking anyone. /// - public static ValueTask Get(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + public static ValueTask> Get(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HMGET}{key}{fields}", flags.WithDefaultCategory(RedisCommand.HMGET)); + + /// Get, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Get. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -89,7 +101,17 @@ public static ValueTask Get(this in RespHashes hashes, RedisKey ke /// The hash command group. /// The key to read. /// Command flags. - public static ValueTask GetAll(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + public static ValueTask> GetAll(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync>( + $"{RedisCommand.HGETALL}{key}", flags.WithDefaultCategory(RedisCommand.HGETALL)); + + /// GetAll, as an array, for the old IDatabase surface. + /// + /// Internal sibling of GetAll. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetAllArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HGETALL}{key}", flags.WithDefaultCategory(RedisCommand.HGETALL)); @@ -97,7 +119,17 @@ public static ValueTask GetAll(this in RespHashes hashes, RedisKey /// The hash command group. /// The key to read. /// Command flags. - public static ValueTask Keys(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + public static ValueTask> Keys(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync>( + $"{RedisCommand.HKEYS}{key}", flags.WithDefaultCategory(RedisCommand.HKEYS)); + + /// Keys, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Keys. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask KeysArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HKEYS}{key}", flags.WithDefaultCategory(RedisCommand.HKEYS)); @@ -105,7 +137,17 @@ public static ValueTask Keys(this in RespHashes hashes, RedisKey k /// The hash command group. /// The key to read. /// Command flags. - public static ValueTask Values(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + public static ValueTask> Values(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync>( + $"{RedisCommand.HVALS}{key}", flags.WithDefaultCategory(RedisCommand.HVALS)); + + /// Values, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Values. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask ValuesArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HVALS}{key}", flags.WithDefaultCategory(RedisCommand.HVALS)); @@ -148,7 +190,17 @@ public static ValueTask RandomField(this in RespHashes hashes, Redis /// The key to read. /// How many to take; a negative count allows repeats. /// Command flags. - public static ValueTask RandomFields(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> RandomFields(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync>( + $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + + /// RandomFields, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RandomFields. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RandomFieldsArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); @@ -157,7 +209,18 @@ public static ValueTask RandomFields(this in RespHashes hashes, Re /// The key to read. /// How many to take; a negative count allows repeats. /// Command flags. - public static ValueTask RandomFieldsWithValues(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> RandomFieldsWithValues(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => hashes.Context.SendAsync>( + $"{RedisCommand.HRANDFIELD}{key}{count}{RespLiterals.WithValues}", + flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + + /// RandomFieldsWithValues, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RandomFieldsWithValues. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RandomFieldsWithValuesArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HRANDFIELD}{key}{count}{RespLiterals.WithValues}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); @@ -289,7 +352,29 @@ public static ValueTask Increment(this in RespHashes hashes, RedisKey ke /// with a different reply. An absent expiry is likewise not a request. /// /// - public static ValueTask Expire( + public static ValueTask> Expire( + this in RespHashes hashes, + RedisKey key, + ReadOnlySpan fields, + Expiration expiry, + ExpireWhen when = ExpireWhen.Always, + CommandFlags flags = CommandFlags.None) + { + if (fields.IsEmpty) return new ValueTask>(ReadOnlyLease.Empty); + + var command = SelectExpireCommand(expiry); + return hashes.Context.SendAsync>( + $"{command}{key}{expiry.Value}{AsFragment(when)}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithRetryCategory(when.AsRetryCategory()).WithDefaultCategory(command)); + } + + /// Expire, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Expire. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask ExpireArray( this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, @@ -310,7 +395,20 @@ public static ValueTask Expire( /// The key to write. /// The fields to persist. /// Command flags. - public static ValueTask Persist(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + public static ValueTask> Persist(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HPERSIST}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPERSIST)); + + /// Persist, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Persist. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask PersistArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -326,7 +424,20 @@ public static ValueTask Persist(this in RespHashes hashes, Redi /// Always the millisecond command, as on the old surface: a caller who wanted seconds can divide, /// and a caller who needed milliseconds could not recover them. /// - public static ValueTask GetTimeToLive(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + public static ValueTask> GetTimeToLive(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HPTTL}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPTTL)); + + /// GetTimeToLive, as an array, for the old IDatabase surface. + /// + /// Internal sibling of GetTimeToLive. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetTimeToLiveArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -339,7 +450,20 @@ public static ValueTask GetTimeToLive(this in RespHashes hashes, RedisKe /// The fields to ask about. /// Command flags. /// - public static ValueTask GetExpireDateTime(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + public static ValueTask> GetExpireDateTime(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HPEXPIRETIME}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HPEXPIRETIME)); + + /// GetExpireDateTime, as an array, for the old IDatabase surface. + /// + /// Internal sibling of GetExpireDateTime. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetExpireDateTimeArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -364,7 +488,20 @@ public static ValueTask GetDelete(this in RespHashes hashes, RedisKe /// The key to write. /// The fields to read and remove. /// Command flags. - public static ValueTask GetDelete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + public static ValueTask> GetDelete(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HGETDEL}{key}{RespLiterals.Fields}{fields.Length}{fields}", + flags.WithDefaultCategory(RedisCommand.HGETDEL)); + + /// GetDelete, as an array, for the old IDatabase surface. + /// + /// Internal sibling of GetDelete. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetDeleteArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( @@ -405,7 +542,20 @@ public static ValueTask GetSetExpiry(this in RespHashes hashes, Redi /// The fields to read. /// The expiration to apply. /// Command flags. - public static ValueTask GetSetExpiry(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + public static ValueTask> GetSetExpiry(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => fields.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : hashes.Context.SendAsync>( + $"{RedisCommand.HGETEX}{key}{expiry}{RespLiterals.Fields}{fields.Length}{fields}", + WithGetExCategory(expiry, flags)); + + /// GetSetExpiry, as an array, for the old IDatabase surface. + /// + /// Internal sibling of GetSetExpiry. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask GetSetExpiryArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs index a2e77a217..9d5adbaee 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs @@ -95,7 +95,19 @@ public static ValueTask Contains(this in RespSets sets, RedisKey key, Redi /// The key to read. /// The members to look for. /// Command flags. - public static ValueTask Contains(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) + public static ValueTask> Contains(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) + => values.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : sets.Context.SendAsync>( + $"{RedisCommand.SMISMEMBER}{key}{values}", flags.WithDefaultCategory(RedisCommand.SMISMEMBER)); + + /// Contains, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Contains. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask ContainsArray(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) => values.IsEmpty ? new ValueTask(Array.Empty()) : sets.Context.SendAsync( @@ -113,7 +125,17 @@ public static ValueTask Length(this in RespSets sets, RedisKey key, Comman /// The set command group. /// The key to read. /// Command flags. - public static ValueTask Members(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) + public static ValueTask> Members(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync>( + $"{RedisCommand.SMEMBERS}{key}", flags.WithDefaultCategory(RedisCommand.SMEMBERS)); + + /// Members, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Members. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask MembersArray(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( $"{RedisCommand.SMEMBERS}{key}", flags.WithDefaultCategory(RedisCommand.SMEMBERS)); @@ -145,7 +167,19 @@ public static ValueTask Pop(this in RespSets sets, RedisKey key, Com /// sends a bare SPOP and would remove one. That is a divergence, and a deliberate /// one: "pop none" quietly popping one is the kind of thing a caller discovers in production. /// - public static ValueTask Pop(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> Pop(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => count == 0 + ? new ValueTask>(ReadOnlyLease.Empty) + : sets.Context.SendAsync>( + $"{RedisCommand.SPOP}{key}{count}", flags.WithDefaultCategory(RedisCommand.SPOP)); + + /// Pop, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Pop. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask PopArray(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => count == 0 ? new ValueTask(Array.Empty()) : sets.Context.SendAsync( @@ -164,7 +198,17 @@ public static ValueTask RandomMember(this in RespSets sets, RedisKey /// The key to read. /// How many to take; a negative count allows repeats. /// Command flags. - public static ValueTask RandomMembers(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> RandomMembers(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sets.Context.SendAsync>( + $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + + /// RandomMembers, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RandomMembers. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RandomMembersArray(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); @@ -178,7 +222,21 @@ public static ValueTask RandomMembers(this in RespSets sets, Redis /// building a variadic message used to be work, and with a run of keys as a hole it is the same /// expression either way. /// - public static ValueTask Combine(this in RespSets sets, SetOperation operation, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + public static ValueTask> Combine(this in RespSets sets, SetOperation operation, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) + { + if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); + + var command = operation.ToSetCommand(); + return sets.Context.SendAsync>($"{command}{keys}", flags.WithDefaultCategory(command)); + } + + /// Combine, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Combine. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask CombineArray(this in RespSets sets, SetOperation operation, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) { if (keys.IsEmpty) throw new ArgumentException("At least one key is required.", nameof(keys)); diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs index acdff7f12..a31704d66 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs @@ -179,7 +179,19 @@ public static ValueTask Remove(this in RespSortedSets sortedSets, RedisKey /// The key to read. /// The members to look up. /// Command flags. - public static ValueTask Scores(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) + public static ValueTask> Scores(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) + => members.IsEmpty + ? new ValueTask>(ReadOnlyLease.Empty) + : sortedSets.Context.SendAsync>( + $"{RedisCommand.ZMSCORE}{key}{members}", flags.WithDefaultCategory(RedisCommand.ZMSCORE)); + + /// Scores, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Scores. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask ScoresArray(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) => members.IsEmpty ? new ValueTask(Array.Empty()) : sortedSets.Context.SendAsync( @@ -263,7 +275,17 @@ public static ValueTask RandomMember(this in RespSortedSets sortedSe /// The key to read. /// How many to take; a negative count allows repeats. /// Command flags. - public static ValueTask RandomMembers(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> RandomMembers(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync>( + $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + + /// RandomMembers, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RandomMembers. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RandomMembersArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); @@ -272,7 +294,18 @@ public static ValueTask RandomMembers(this in RespSortedSets sorte /// The key to read. /// How many to take; a negative count allows repeats. /// Command flags. - public static ValueTask RandomMembersWithScores(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + public static ValueTask> RandomMembersWithScores(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => sortedSets.Context.SendAsync>( + $"{RedisCommand.ZRANDMEMBER}{key}{count}{RespLiterals.WithScores}", + flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + + /// RandomMembersWithScores, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RandomMembersWithScores. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RandomMembersWithScoresArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( $"{RedisCommand.ZRANDMEMBER}{key}{count}{RespLiterals.WithScores}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); @@ -286,7 +319,26 @@ public static ValueTask RandomMembersWithScores(this in RespSo /// The last rank to take. /// Which end to count from. /// Command flags. - public static ValueTask RangeByRank( + public static ValueTask> RangeByRank( + this in RespSortedSets sortedSets, + RedisKey key, + long start = 0, + long stop = -1, + Order order = Order.Ascending, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGE : RedisCommand.ZRANGE; + return sortedSets.Context.SendAsync>( + $"{command}{key}{start}{stop}", flags.WithDefaultCategory(command)); + } + + /// RangeByRank, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RangeByRank. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RangeByRankArray( this in RespSortedSets sortedSets, RedisKey key, long start = 0, @@ -306,7 +358,26 @@ public static ValueTask RangeByRank( /// The last rank to take. /// Which end to count from. /// Command flags. - public static ValueTask RangeByRankWithScores( + public static ValueTask> RangeByRankWithScores( + this in RespSortedSets sortedSets, + RedisKey key, + long start = 0, + long stop = -1, + Order order = Order.Ascending, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGE : RedisCommand.ZRANGE; + return sortedSets.Context.SendAsync>( + $"{command}{key}{start}{stop}{RespLiterals.WithScores}", flags.WithDefaultCategory(command)); + } + + /// RangeByRankWithScores, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RangeByRankWithScores. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RangeByRankWithScoresArray( this in RespSortedSets sortedSets, RedisKey key, long start = 0, @@ -335,7 +406,25 @@ public static ValueTask RangeByRankWithScores( /// asked to walk. That is the old builder's rule, kept exactly, because a caller who passed /// (10, 1) descending has always meant the same thing. /// - public static ValueTask RangeByScore( + public static ValueTask> RangeByScore( + this in RespSortedSets sortedSets, + RedisKey key, + double start = double.NegativeInfinity, + double stop = double.PositiveInfinity, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + => RangeByScoreCore>(in sortedSets, key, start, stop, exclude, order, skip, take, withScores: false, flags); + + /// RangeByScore, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RangeByScore. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RangeByScoreArray( this in RespSortedSets sortedSets, RedisKey key, double start = double.NegativeInfinity, @@ -357,7 +446,25 @@ public static ValueTask RangeByScore( /// How many to discard from the front. /// How many to return; -1 for all. /// Command flags. - public static ValueTask RangeByScoreWithScores( + public static ValueTask> RangeByScoreWithScores( + this in RespSortedSets sortedSets, + RedisKey key, + double start = double.NegativeInfinity, + double stop = double.PositiveInfinity, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + => RangeByScoreCore>(in sortedSets, key, start, stop, exclude, order, skip, take, withScores: true, flags); + + /// RangeByScoreWithScores, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RangeByScoreWithScores. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RangeByScoreWithScoresArray( this in RespSortedSets sortedSets, RedisKey key, double start = double.NegativeInfinity, @@ -384,7 +491,35 @@ public static ValueTask RangeByScoreWithScores( /// the open bounds then flip too, which is why - and + are chosen by the order /// rather than by the position. /// - public static ValueTask RangeByValue( + public static ValueTask> RangeByValue( + this in RespSortedSets sortedSets, + RedisKey key, + RedisValue min = default, + RedisValue max = default, + Exclude exclude = Exclude.None, + Order order = Order.Ascending, + long skip = 0, + long take = -1, + CommandFlags flags = CommandFlags.None) + { + var command = order == Order.Descending ? RedisCommand.ZREVRANGEBYLEX : RedisCommand.ZRANGEBYLEX; + + // the bounds stay in start-then-stop order even for the reversed command; what reverses is + // which of them is "low", and GetLexRange's order-aware -/+ mapping is where that lives + RedisDatabase.ReverseLimits(order, ref exclude, ref min, ref max); + + return sortedSets.Context.SendAsync>( + $"{command}{key}{Lex(min, exclude, isStart: true, order)}{Lex(max, exclude, isStart: false, order)}{new RespLimitRange(skip, take)}", + flags.WithDefaultCategory(command)); + } + + /// RangeByValue, as an array, for the old IDatabase surface. + /// + /// Internal sibling of RangeByValue. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask RangeByValueArray( this in RespSortedSets sortedSets, RedisKey key, RedisValue min = default, @@ -518,7 +653,25 @@ public static ValueTask RemoveRangeByValue(this in RespSortedSets sortedSe /// A multiplier per key, or for all ones. /// How to fold the scores of a member present in several keys. /// Command flags. - public static ValueTask Combine( + public static ValueTask> Combine( + this in RespSortedSets sortedSets, + SetOperation operation, + ReadOnlySpan keys, + ReadOnlySpan weights = default, + Aggregate aggregate = Aggregate.Sum, + CommandFlags flags = CommandFlags.None) + { + var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); + return CombineCore>(in sortedSets, command, destination: default, keys, weights, aggregate, withScores: false, flags); + } + + /// Combine, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Combine. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask CombineArray( this in RespSortedSets sortedSets, SetOperation operation, ReadOnlySpan keys, @@ -537,7 +690,25 @@ public static ValueTask Combine( /// A multiplier per key, or for all ones. /// How to fold the scores of a member present in several keys. /// Command flags. - public static ValueTask CombineWithScores( + public static ValueTask> CombineWithScores( + this in RespSortedSets sortedSets, + SetOperation operation, + ReadOnlySpan keys, + ReadOnlySpan weights = default, + Aggregate aggregate = Aggregate.Sum, + CommandFlags flags = CommandFlags.None) + { + var command = ValidateCombine(operation.ToSortedSetCommand(), keys, weights, aggregate); + return CombineCore>(in sortedSets, command, destination: default, keys, weights, aggregate, withScores: true, flags); + } + + /// CombineWithScores, as an array, for the old IDatabase surface. + /// + /// Internal sibling of CombineWithScores. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask CombineWithScoresArray( this in RespSortedSets sortedSets, SetOperation operation, ReadOnlySpan keys, @@ -603,7 +774,23 @@ public static ValueTask CombineLength(this in RespSortedSets sortedSets, R /// How many to take. /// Which end to take from. /// Command flags. - public static ValueTask Pop(this in RespSortedSets sortedSets, RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + public static ValueTask> Pop(this in RespSortedSets sortedSets, RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + { + // unlike SPOP, a count of zero here is well defined on the wire - but sending it is a round + // trip to be told nothing, which the old surface also declines to make + if (count == 0) return new ValueTask>(ReadOnlyLease.Empty); + + var command = order == Order.Descending ? RedisCommand.ZPOPMAX : RedisCommand.ZPOPMIN; + return sortedSets.Context.SendAsync>($"{command}{key}{count}", flags.WithDefaultCategory(command)); + } + + /// Pop, as an array, for the old IDatabase surface. + /// + /// Internal sibling of Pop. A sibling rather than a conversion: IDatabase promises + /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of + /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// + internal static ValueTask PopArray(this in RespSortedSets sortedSets, RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) { // unlike SPOP, a count of zero here is well defined on the wire - but sending it is a round // trip to be told nothing, which the old surface also declines to make diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 7187c50b0..4567aa4aa 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -56,7 +56,7 @@ public static class RespHandlers public static IRespHandler Double { get; } = DefaultHandlers.Instance; /// Reads an array reply as s; a nil array reads as empty. - public static IRespHandler Values { get; } = DefaultHandlers.Instance; + internal static IRespHandler Values { get; } = DefaultHandlers.Instance; /// Reads an array reply into a pooled the caller gives back. /// @@ -161,29 +161,36 @@ internal static IRespHandler Require() /// private sealed class DefaultHandlers : IRespHandler, + IRespHandler>, IRespHandler, + IRespHandler>, IRespHandler, IRespHandler?>, IRespHandler>, IRespHandler, + IRespHandler>, IRespHandler?>, IRespHandler, IRespHandler, IRespHandler>, IRespHandler, IRespHandler, + IRespHandler>, IRespHandler, IRespHandler>, IRespHandler>, IRespHandler, IRespHandler, IRespHandler, + IRespHandler>, IRespHandler, IRespHandler, IRespHandler, + IRespHandler>, IRespHandler, IRespHandler, IRespHandler, + IRespHandler>, IRespHandler, IRespPayloadHandler { @@ -270,6 +277,86 @@ ReadOnlyLease IRespHandler>.Parse(ReadOnly } } + /// + /// Read an aggregate of scalars straight into a pooled lease. + /// + /// + /// The lease counterpart of ReadPastArray(projection, scalar: true): same walk, same + /// projection, but filling storage the caller gives back instead of a fresh array. Six of the + /// element types here differ only in that projection, so they share this rather than repeating + /// the rent-and-guard dance eight times. + /// + private static ReadOnlyLease ReadScalarLease(ReadOnlySpan response, RespReader.Projection projection) + { + var reader = new RespReader(response); + reader.MoveNext(); + + // a nil aggregate reads as empty, as it does for the array handlers: every caller of an + // array reply wants to iterate it + if (reader.IsNull) return ReadOnlyLease.Empty; + + var count = reader.AggregateLength(); + if (count <= 0) return ReadOnlyLease.Empty; + + var lease = ReadOnlyLease.Rent(count, null, out var target); + try + { + var iter = reader.AggregateChildren(); + for (var i = 0; i < count; i++) + { + iter.DemandNext(); + var element = iter.Value; + target[i] = projection(ref element); + } + + return lease; + } + catch + { + // rented by now, and nobody else has a reference to hand back + lease.Dispose(); + throw; + } + } + + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + => ReadScalarLease(response, static (ref r) => r.ReadInt64()); + + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + => ReadScalarLease(response, static (ref r) => r.ReadBoolean()); + + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + => ReadScalarLease(response, static (ref r) => (ExpireResult)r.ReadInt64()); + + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + => ReadScalarLease(response, static (ref r) => (PersistResult)r.ReadInt64()); + + /// ZMSCORE replies nil for a member that is not there, so the element type is nullable. + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + => ReadScalarLease(response, static (ref r) => r.IsNull ? (double?)null : r.ReadDouble()); + + /// + /// No copy. ParseArray(allowOversized: true) already rents from + /// ArrayPool and reports the live length separately, which is exactly a lease + /// wearing different clothes - so this adopts the rental rather than copying out of it. + /// + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + var pooled = HashEntryShape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: true, out var count, state: null); + return pooled is null ? ReadOnlyLease.Empty : ReadOnlyLease.Adopt(pooled, count); + } + + /// + ReadOnlyLease IRespHandler>.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + var pooled = SortedSetEntryShape.ParseArray(ref reader, RedisProtocol.Resp3, allowOversized: true, out var count, state: null); + return pooled is null ? ReadOnlyLease.Empty : ReadOnlyLease.Adopt(pooled, count); + } + RedisValue[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs index d12bc7107..a9b2afde7 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Hashes.cs @@ -32,11 +32,11 @@ public Task HashGetAsync(RedisKey key, RedisValue hashField, Command /// public RedisValue[] HashGet(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Get(key, Required(hashFields, nameof(hashFields)), flags)); + => Wait(Context.Hashes.GetArray(key, Required(hashFields, nameof(hashFields)), flags)); /// public Task HashGetAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Get(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + => Context.Hashes.GetArray(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); /// public Lease? HashGetLease(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) @@ -48,27 +48,27 @@ public Task HashGetAsync(RedisKey key, RedisValue[] hashFields, Co /// public HashEntry[] HashGetAll(RedisKey key, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetAll(key, flags)); + => Wait(Context.Hashes.GetAllArray(key, flags)); /// public Task HashGetAllAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetAll(key, flags).AsTask(); + => Context.Hashes.GetAllArray(key, flags).AsTask(); /// public RedisValue[] HashKeys(RedisKey key, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Keys(key, flags)); + => Wait(Context.Hashes.KeysArray(key, flags)); /// public Task HashKeysAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Keys(key, flags).AsTask(); + => Context.Hashes.KeysArray(key, flags).AsTask(); /// public RedisValue[] HashValues(RedisKey key, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Values(key, flags)); + => Wait(Context.Hashes.ValuesArray(key, flags)); /// public Task HashValuesAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Values(key, flags).AsTask(); + => Context.Hashes.ValuesArray(key, flags).AsTask(); /// public long HashLength(RedisKey key, CommandFlags flags = CommandFlags.None) @@ -104,19 +104,19 @@ public Task HashRandomFieldAsync(RedisKey key, CommandFlags flags = /// public RedisValue[] HashRandomFields(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.RandomFields(key, count, flags)); + => Wait(Context.Hashes.RandomFieldsArray(key, count, flags)); /// public Task HashRandomFieldsAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.Hashes.RandomFields(key, count, flags).AsTask(); + => Context.Hashes.RandomFieldsArray(key, count, flags).AsTask(); /// public HashEntry[] HashRandomFieldsWithValues(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.RandomFieldsWithValues(key, count, flags)); + => Wait(Context.Hashes.RandomFieldsWithValuesArray(key, count, flags)); /// public Task HashRandomFieldsWithValuesAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.Hashes.RandomFieldsWithValues(key, count, flags).AsTask(); + => Context.Hashes.RandomFieldsWithValuesArray(key, count, flags).AsTask(); /// public bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) @@ -191,43 +191,43 @@ public Task HashDecrementAsync(RedisKey key, RedisValue hashField, doubl /// public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); + => Wait(Context.Hashes.ExpireArray(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); /// public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); + => Context.Hashes.ExpireArray(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); /// public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); + => Wait(Context.Hashes.ExpireArray(key, Required(hashFields, nameof(hashFields)), expiry, when, flags)); /// public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Expire(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); + => Context.Hashes.ExpireArray(key, Required(hashFields, nameof(hashFields)), expiry, when, flags).AsTask(); /// public PersistResult[] HashFieldPersist(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.Persist(key, Required(hashFields, nameof(hashFields)), flags)); + => Wait(Context.Hashes.PersistArray(key, Required(hashFields, nameof(hashFields)), flags)); /// public Task HashFieldPersistAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Context.Hashes.Persist(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + => Context.Hashes.PersistArray(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); /// public long[] HashFieldGetTimeToLive(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetTimeToLive(key, Required(hashFields, nameof(hashFields)), flags)); + => Wait(Context.Hashes.GetTimeToLiveArray(key, Required(hashFields, nameof(hashFields)), flags)); /// public Task HashFieldGetTimeToLiveAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetTimeToLive(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + => Context.Hashes.GetTimeToLiveArray(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); /// public long[] HashFieldGetExpireDateTime(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetExpireDateTime(key, Required(hashFields, nameof(hashFields)), flags)); + => Wait(Context.Hashes.GetExpireDateTimeArray(key, Required(hashFields, nameof(hashFields)), flags)); /// public Task HashFieldGetExpireDateTimeAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetExpireDateTime(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + => Context.Hashes.GetExpireDateTimeArray(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); // ---- read/write combinations ------------------------------------------------------------------- @@ -241,11 +241,11 @@ public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue hash /// public RedisValue[] HashFieldGetAndDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetDelete(key, Required(hashFields, nameof(hashFields)), flags)); + => Wait(Context.Hashes.GetDeleteArray(key, Required(hashFields, nameof(hashFields)), flags)); /// public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetDelete(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); + => Context.Hashes.GetDeleteArray(key, Required(hashFields, nameof(hashFields)), flags).AsTask(); /// public Lease? HashFieldGetLeaseAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) @@ -276,19 +276,19 @@ public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue h /// public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags)); + => Wait(Context.Hashes.GetSetExpiryArray(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags)); /// public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags).AsTask(); + => Context.Hashes.GetSetExpiryArray(key, Required(hashFields, nameof(hashFields)), Expiration.CreateOrPersist(expiry, persist), flags).AsTask(); /// public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) - => Wait(Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags)); + => Wait(Context.Hashes.GetSetExpiryArray(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags)); /// public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) - => Context.Hashes.GetSetExpiry(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags).AsTask(); + => Context.Hashes.GetSetExpiryArray(key, Required(hashFields, nameof(hashFields)), new Expiration(expiry), flags).AsTask(); /// public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs index 3f7f67746..47b87b8ee 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.Sets.cs @@ -55,11 +55,11 @@ public Task SetContainsAsync(RedisKey key, RedisValue value, CommandFlags /// public bool[] SetContains(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.Contains(key, Required(values, nameof(values)), flags)); + => Wait(Context.Sets.ContainsArray(key, Required(values, nameof(values)), flags)); /// public Task SetContainsAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => Context.Sets.Contains(key, Required(values, nameof(values)), flags).AsTask(); + => Context.Sets.ContainsArray(key, Required(values, nameof(values)), flags).AsTask(); /// public long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None) @@ -71,11 +71,11 @@ public Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags /// public RedisValue[] SetMembers(RedisKey key, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.Members(key, flags)); + => Wait(Context.Sets.MembersArray(key, flags)); /// public Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => Context.Sets.Members(key, flags).AsTask(); + => Context.Sets.MembersArray(key, flags).AsTask(); /// public bool SetMove(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) @@ -95,11 +95,11 @@ public Task SetPopAsync(RedisKey key, CommandFlags flags = CommandFl /// public RedisValue[] SetPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.Pop(key, count, flags)); + => Wait(Context.Sets.PopArray(key, count, flags)); /// public Task SetPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.Sets.Pop(key, count, flags).AsTask(); + => Context.Sets.PopArray(key, count, flags).AsTask(); /// public RedisValue SetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) @@ -111,30 +111,30 @@ public Task SetRandomMemberAsync(RedisKey key, CommandFlags flags = /// public RedisValue[] SetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.RandomMembers(key, count, flags)); + => Wait(Context.Sets.RandomMembersArray(key, count, flags)); /// public Task SetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.Sets.RandomMembers(key, count, flags).AsTask(); + => Context.Sets.RandomMembersArray(key, count, flags).AsTask(); // the (first, second) overloads are the old spelling of a two-key run; unpacking them here is the // whole of the difference, and a null `second` is how that surface says "just the one" /// public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.Combine(operation, Pair(first, second), flags)); + => Wait(Context.Sets.CombineArray(operation, Pair(first, second), flags)); /// public Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => Context.Sets.Combine(operation, Pair(first, second), flags).AsTask(); + => Context.Sets.CombineArray(operation, Pair(first, second), flags).AsTask(); /// public RedisValue[] SetCombine(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => Wait(Context.Sets.Combine(operation, Required(keys, nameof(keys)), flags)); + => Wait(Context.Sets.CombineArray(operation, Required(keys, nameof(keys)), flags)); /// public Task SetCombineAsync(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => Context.Sets.Combine(operation, Required(keys, nameof(keys)), flags).AsTask(); + => Context.Sets.CombineArray(operation, Required(keys, nameof(keys)), flags).AsTask(); /// public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs index 3c4bb34d8..32b249348 100644 --- a/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs +++ b/src/StackExchange.Redis/Interpolated/TransitionalDatabase.SortedSets.cs @@ -145,11 +145,11 @@ public Task SortedSetRemoveAsync(RedisKey key, RedisValue[] members, Comma /// public double?[] SortedSetScores(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.Scores(key, Required(members, nameof(members)), flags)); + => Wait(Context.SortedSets.ScoresArray(key, Required(members, nameof(members)), flags)); /// public Task SortedSetScoresAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.Scores(key, Required(members, nameof(members)), flags).AsTask(); + => Context.SortedSets.ScoresArray(key, Required(members, nameof(members)), flags).AsTask(); /// public long SortedSetLength(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) @@ -185,53 +185,53 @@ public Task SortedSetRandomMemberAsync(RedisKey key, CommandFlags fl /// public RedisValue[] SortedSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RandomMembers(key, count, flags)); + => Wait(Context.SortedSets.RandomMembersArray(key, count, flags)); /// public Task SortedSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RandomMembers(key, count, flags).AsTask(); + => Context.SortedSets.RandomMembersArray(key, count, flags).AsTask(); /// public SortedSetEntry[] SortedSetRandomMembersWithScores(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RandomMembersWithScores(key, count, flags)); + => Wait(Context.SortedSets.RandomMembersWithScoresArray(key, count, flags)); /// public Task SortedSetRandomMembersWithScoresAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RandomMembersWithScores(key, count, flags).AsTask(); + => Context.SortedSets.RandomMembersWithScoresArray(key, count, flags).AsTask(); // ---- ranges ------------------------------------------------------------------------------------ /// public RedisValue[] SortedSetRangeByRank(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RangeByRank(key, start, stop, order, flags)); + => Wait(Context.SortedSets.RangeByRankArray(key, start, stop, order, flags)); /// public Task SortedSetRangeByRankAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RangeByRank(key, start, stop, order, flags).AsTask(); + => Context.SortedSets.RangeByRankArray(key, start, stop, order, flags).AsTask(); /// public SortedSetEntry[] SortedSetRangeByRankWithScores(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RangeByRankWithScores(key, start, stop, order, flags)); + => Wait(Context.SortedSets.RangeByRankWithScoresArray(key, start, stop, order, flags)); /// public Task SortedSetRangeByRankWithScoresAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RangeByRankWithScores(key, start, stop, order, flags).AsTask(); + => Context.SortedSets.RangeByRankWithScoresArray(key, start, stop, order, flags).AsTask(); /// public RedisValue[] SortedSetRangeByScore(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RangeByScore(key, start, stop, exclude, order, skip, take, flags)); + => Wait(Context.SortedSets.RangeByScoreArray(key, start, stop, exclude, order, skip, take, flags)); /// public Task SortedSetRangeByScoreAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RangeByScore(key, start, stop, exclude, order, skip, take, flags).AsTask(); + => Context.SortedSets.RangeByScoreArray(key, start, stop, exclude, order, skip, take, flags).AsTask(); /// public SortedSetEntry[] SortedSetRangeByScoreWithScores(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags)); + => Wait(Context.SortedSets.RangeByScoreWithScoresArray(key, start, stop, exclude, order, skip, take, flags)); /// public Task SortedSetRangeByScoreWithScoresAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags).AsTask(); + => Context.SortedSets.RangeByScoreWithScoresArray(key, start, stop, exclude, order, skip, take, flags).AsTask(); /// public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) @@ -243,11 +243,11 @@ public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue mi /// public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.RangeByValue(key, min, max, exclude, order, skip, take, flags)); + => Wait(Context.SortedSets.RangeByValueArray(key, min, max, exclude, order, skip, take, flags)); /// public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.RangeByValue(key, min, max, exclude, order, skip, take, flags).AsTask(); + => Context.SortedSets.RangeByValueArray(key, min, max, exclude, order, skip, take, flags).AsTask(); /// public long SortedSetRangeAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) @@ -287,19 +287,19 @@ public Task SortedSetRemoveRangeByValueAsync(RedisKey key, RedisValue min, /// public RedisValue[] SortedSetCombine(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.Combine(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); + => Wait(Context.SortedSets.CombineArray(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); /// public Task SortedSetCombineAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.Combine(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); + => Context.SortedSets.CombineArray(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); /// public SortedSetEntry[] SortedSetCombineWithScores(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.CombineWithScores(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); + => Wait(Context.SortedSets.CombineWithScoresArray(operation, Required(keys, nameof(keys)), weights, aggregate, flags)); /// public Task SortedSetCombineWithScoresAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.CombineWithScores(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); + => Context.SortedSets.CombineWithScoresArray(operation, Required(keys, nameof(keys)), weights, aggregate, flags).AsTask(); /// public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) @@ -337,11 +337,11 @@ public Task SortedSetIntersectionLengthAsync(RedisKey[] keys, long limit = /// public SortedSetEntry[] SortedSetPop(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Wait(Context.SortedSets.Pop(key, count, order, flags)); + => Wait(Context.SortedSets.PopArray(key, count, order, flags)); /// public Task SortedSetPopAsync(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => Context.SortedSets.Pop(key, count, order, flags).AsTask(); + => Context.SortedSets.PopArray(key, count, order, flags).AsTask(); /// public SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 9d10b3bd6..c65010496 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,6 +1,52 @@ #nullable enable +*REMOVED*static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? StackExchange.Redis.CommandFlags.NoClientCache = 524288 -> StackExchange.Redis.CommandFlags StackExchange.Redis.CommandFlagsExtensions +StackExchange.Redis.ReadOnlyLease +StackExchange.Redis.ReadOnlyLease.Dispose() -> void +StackExchange.Redis.ReadOnlyLease.IsEmpty.get -> bool +StackExchange.Redis.ReadOnlyLease.Length.get -> int +StackExchange.Redis.ReadOnlyLease.Memory.get -> System.ReadOnlyMemory +StackExchange.Redis.ReadOnlyLease.Span.get -> System.ReadOnlySpan +StackExchange.Redis.ReadOnlyLease.ToArray() -> T[]! +StackExchange.Redis.RespReaderLeaseExtensions +[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CacheOptions? +[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.set -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions +[SER010]StackExchange.Redis.Interpolated.CacheOptions.CacheOptions() -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.get -> StackExchange.Redis.Interpolated.CachePolicy! +[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.get -> bool +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.get -> int +[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.get -> long? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.get -> int? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.get -> int? +[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.get -> System.Collections.Generic.IReadOnlyList! +[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.get -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.init -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy +[SER010]StackExchange.Redis.Interpolated.CachePolicy.CachePolicy() -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.init -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.init -> void +[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.get -> System.TimeSpan +[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.init -> void +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.Broadcast = 0 -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.PerKey = 1 -> StackExchange.Redis.Interpolated.CacheTrackingMode +[SER010]StackExchange.Redis.Interpolated.IRespArgument +[SER010]StackExchange.Redis.Interpolated.IRespArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void +[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument +[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler, string? format) -> void [SER010]StackExchange.Redis.Interpolated.IRespHandler [SER010]StackExchange.Redis.Interpolated.IRespHandler.Parse(System.ReadOnlySpan response) -> TResult [SER010]StackExchange.Redis.Interpolated.IRespTarget @@ -20,29 +66,45 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespBitmaps.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespBitmaps.RespBitmaps() -> void [SER010]StackExchange.Redis.Interpolated.RespBitmaps.RespBitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespCacheConnectionExtensions [SER010]StackExchange.Redis.Interpolated.RespClientCache +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Bytes.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Coalesced.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.Count.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.Dispose() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.EndRefresh(in StackExchange.Redis.Interpolated.RespRequest frame, int database) -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Evicted.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Expired.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.InFlightCount.get -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnFlush() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.OnInvalidate(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.OnLocalWrite(System.ReadOnlySpan key) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Options.get -> StackExchange.Redis.Interpolated.CacheOptions! +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Policy.get -> StackExchange.Redis.Interpolated.CachePolicy! [SER010]StackExchange.Redis.Interpolated.RespClientCache.RedundantFills.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Coalesced.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.InFlightCount.get -> int -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryAwaitInFlight(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out System.Threading.Tasks.Task? pending) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.Refreshes.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedByFlags.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedError.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNoKeys.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNotTracked.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedRaced.get -> long +[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedTooLarge.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespClientCache(StackExchange.Redis.Interpolated.CacheOptions? options = null, int keyCapacity = 256) -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.Abandon() -> void [SER010]StackExchange.Redis.Interpolated.RespClientCache.RespFill.RespFill() -> void +[SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.Stored.get -> long [SER010]StackExchange.Redis.Interpolated.RespClientCache.Sweep() -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.SweepIfDue() -> int [SER010]StackExchange.Redis.Interpolated.RespClientCache.TrackedKeyCount.get -> int +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryAwaitInFlight(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out System.Threading.Tasks.Task? pending) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, StackExchange.Redis.CommandFlags flags, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginFill(ref StackExchange.Redis.Interpolated.RespFrame frame, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginRefresh(in StackExchange.Redis.Interpolated.RespRequest request, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryComplete(in StackExchange.Redis.Interpolated.RespClientCache.RespFill fill, StackExchange.Redis.Interpolated.RespPayload! response) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool +[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload, out bool shouldRefresh) -> bool [SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool [SER010]StackExchange.Redis.Interpolated.RespCommand [SER010]StackExchange.Redis.Interpolated.RespCommand.IsEmpty.get -> bool @@ -50,21 +112,17 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespCommand.IsPreformed.get -> bool [SER010]StackExchange.Redis.Interpolated.RespCommand.RespCommand() -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendBulk(scoped System.ReadOnlySpan payload) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespCommand value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.Interpolated.RespFragment value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisChannel value) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendBulk(scoped System.ReadOnlySpan payload) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void +[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan> value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(T value, string? format) -> void -[SER010]StackExchange.Redis.Interpolated.IRespArgument -[SER010]StackExchange.Redis.Interpolated.IRespArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void -[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument -[SER010]StackExchange.Redis.Interpolated.IRespFormattableArgument.WriteTo(scoped ref StackExchange.Redis.Interpolated.RespCommandHandler handler, string? format) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisKey value) -> void -[SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(StackExchange.Redis.RedisValue value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendFormatted(scoped System.ReadOnlySpan value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.AppendLiteral(string! value) -> void [SER010]StackExchange.Redis.Interpolated.RespCommandHandler.Complete() -> StackExchange.Redis.Interpolated.RespFrame @@ -82,9 +140,10 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespContext.Compose(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespContext.Compose(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespCommandHandler [SER010]StackExchange.Redis.Interpolated.RespContext.Database.get -> int +[SER010]StackExchange.Redis.Interpolated.RespContext.ExecuteAsync(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.Render(ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame [SER010]StackExchange.Redis.Interpolated.RespContext.Render(string! command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> StackExchange.Redis.Interpolated.RespFrame -[SER010]StackExchange.Redis.Interpolated.RespContext.KeyPrefix.get -> StackExchange.Redis.RedisKey [SER010]StackExchange.Redis.Interpolated.RespContext.RespContext() -> void [SER010]StackExchange.Redis.Interpolated.RespContext.ServerType.get -> StackExchange.Redis.ServerType [SER010]StackExchange.Redis.Interpolated.RespContext.TryGetService(out T? service) -> bool @@ -93,6 +152,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]StackExchange.Redis.Interpolated.RespContext.WithChannelPrefix(StackExchange.Redis.RedisChannel channelPrefix) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithDatabase(int database) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithKeyPrefix(StackExchange.Redis.RedisKey keyPrefix) -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespContext.WithMaxCacheAge(System.TimeSpan maxAge) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServerType(StackExchange.Redis.ServerType serverType) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespContext.WithServices(object? services) -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespDatabase @@ -182,69 +242,75 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]override StackExchange.Redis.Interpolated.RespRequest.Equals(object? obj) -> bool [SER010]override StackExchange.Redis.Interpolated.RespRequest.GetHashCode() -> int [SER010]override StackExchange.Redis.Interpolated.RespRequest.ToString() -> string! +[SER010]static StackExchange.Redis.Interpolated.CacheOptions.Default.get -> StackExchange.Redis.Interpolated.CacheOptions! +[SER010]static StackExchange.Redis.Interpolated.CachePolicy.Default.get -> StackExchange.Redis.Interpolated.CachePolicy! [SER010]static StackExchange.Redis.Interpolated.RespAppend.Append(this ref StackExchange.Redis.Interpolated.RespCommandHandler command, ref StackExchange.Redis.Interpolated.RespCommandHandler handler) -> void +[SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this System.ReadOnlySpan name) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespCommands.Command(this string! name, bool preform = false) -> StackExchange.Redis.Interpolated.RespCommand [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> TResult [SER010]static StackExchange.Redis.Interpolated.RespExecutor.Send(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> TResult +[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler? handler = null) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespFrame request, StackExchange.Redis.CommandFlags flags, StackExchange.Redis.Interpolated.IRespHandler! handler) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespExecutor.SendAsync(this StackExchange.Redis.Interpolated.RespContext context, ref StackExchange.Redis.Interpolated.RespCommandHandler request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespFragment.CreateValidated(System.ReadOnlySpan bytes, int argCount = 1) -> StackExchange.Redis.Interpolated.RespFragment [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Boolean.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Double.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Int64.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Lease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.NullableInt64.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ReadOnlyLease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Result.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.SingletonLease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.SingletonValue.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.String.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Success.get -> StackExchange.Redis.Interpolated.IRespHandler! [SER010]static StackExchange.Redis.Interpolated.RespHandlers.Value.get -> StackExchange.Redis.Interpolated.IRespHandler! -[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Values.get -> StackExchange.Redis.Interpolated.IRespHandler! +[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ValueLease.get -> StackExchange.Redis.Interpolated.IRespHandler!>! [SER010]static StackExchange.Redis.Interpolated.RespPayload.Create(System.ReadOnlySpan value) -> StackExchange.Redis.Interpolated.RespPayload! [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double score, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Add(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, bool change = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Append(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Combine(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineAndStore(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineAndStore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineLength(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, long limit = 0, bool approximate = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineLength(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, System.ReadOnlySpan keys, long limit = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Digest(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.ExecuteAsync(this StackExchange.Redis.Interpolated.IRespTarget! target, string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Exists(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Expire(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Expire(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, System.ReadOnlySpan operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespStrings strings, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> -[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetAll(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetAll(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetDelete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetExpireDateTime(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetExpireDateTime(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLease(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLease(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLeaseDelete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetLeaseSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetRange(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long start, long end, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.GetSetExpiry(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.Expiration expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetTimeToLive(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.GetTimeToLive(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, double value, StackExchange.Redis.SortedSetWhen when = StackExchange.Redis.SortedSetWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -252,7 +318,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.Expiration expiry, double? lowerBound = null, double? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Increment(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, long value, StackExchange.Redis.Expiration expiry, long? lowerBound = null, long? upperBound = null, StackExchange.Redis.IncrementOptions options = StackExchange.Redis.IncrementOptions.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask> -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Keys(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Keys(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Length(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double min = -Infinity, double max = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -261,30 +327,30 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequence(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Members(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Members(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Move(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey source, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Operation(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.Bitwise operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, System.ReadOnlySpan keys, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomField(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMember(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMember(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembersWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembers(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomMembersWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeAndStore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.RedisValue start, StackExchange.Redis.RedisValue stop, StackExchange.Redis.SortedSetOrder sortedSetOrder = StackExchange.Redis.SortedSetOrder.ByRank, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long? take = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRankWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScoreWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue max = default(StackExchange.Redis.RedisValue), StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByRankWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start = 0, long stop = -1, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByScoreWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start = -Infinity, double stop = Infinity, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.RangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue max = default(StackExchange.Redis.RedisValue), StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, long skip = 0, long take = -1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Rank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Remove(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -294,7 +360,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Score(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Scores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Scores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -305,7 +371,7 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.StringLength(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.Values(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Values(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespHashes @@ -318,75 +384,8 @@ StackExchange.Redis.CommandFlagsExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Strings(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespStrings [SER011]StackExchange.Redis.Interpolated.RespFragment.RespFragment(System.ReadOnlySpan bytes, int argCount = 1) -> void static StackExchange.Redis.CommandFlagsExtensions.WithRetryCategory(this StackExchange.Redis.CommandFlags flags, StackExchange.Redis.CommandFlags category) -> StackExchange.Redis.CommandFlags -[SER010]StackExchange.Redis.Interpolated.CachePolicy -[SER010]StackExchange.Redis.Interpolated.CachePolicy.CachePolicy() -> void -[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.get -> System.TimeSpan -[SER010]StackExchange.Redis.Interpolated.CachePolicy.TimeToLive.init -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Expired.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Policy.get -> StackExchange.Redis.Interpolated.CachePolicy! -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload) -> bool -[SER010]StackExchange.Redis.Interpolated.RespContext.WithMaxCacheAge(System.TimeSpan maxAge) -> StackExchange.Redis.Interpolated.RespContext -[SER010]static StackExchange.Redis.Interpolated.CachePolicy.Default.get -> StackExchange.Redis.Interpolated.CachePolicy! -[SER010]static StackExchange.Redis.Interpolated.RespHandlers.Result.get -> StackExchange.Redis.Interpolated.IRespHandler! -[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ReadOnlyLease.get -> StackExchange.Redis.Interpolated.IRespHandler?>! -StackExchange.Redis.ReadOnlyLease -StackExchange.Redis.ReadOnlyLease.Dispose() -> void -StackExchange.Redis.ReadOnlyLease.IsEmpty.get -> bool -StackExchange.Redis.ReadOnlyLease.Length.get -> int -StackExchange.Redis.ReadOnlyLease.Memory.get -> System.ReadOnlyMemory -StackExchange.Redis.ReadOnlyLease.Span.get -> System.ReadOnlySpan -StackExchange.Redis.ReadOnlyLease.ToArray() -> T[]! -StackExchange.Redis.RespReaderLeaseExtensions +static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.ReadOnlyLease? bytes, bool ownsLease = true) -> System.IO.Stream? +static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? static StackExchange.Redis.ReadOnlyLease.Empty.get -> StackExchange.Redis.ReadOnlyLease! static StackExchange.Redis.RespReaderExtensions.ReadLease(in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? static StackExchange.Redis.RespReaderLeaseExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.ReadOnlyLease? -*REMOVED*static StackExchange.Redis.RespReaderExtensions.ReadLease(this in RESPite.Messages.RespReader reader) -> StackExchange.Redis.Lease? -static StackExchange.Redis.ExtensionMethods.AsStream(this StackExchange.Redis.ReadOnlyLease? bytes, bool ownsLease = true) -> System.IO.Stream? -static StackExchange.Redis.ExtensionMethods.DecodeString(this StackExchange.Redis.ReadOnlyLease? bytes, System.Text.Encoding? encoding = null) -> string? -[SER010]StackExchange.Redis.Interpolated.RespContext.ExecuteAsync(string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]static StackExchange.Redis.Interpolated.RespSurface.ExecuteAsync(this StackExchange.Redis.Interpolated.IRespTarget! target, string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask -[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.get -> System.TimeSpan -[SER010]StackExchange.Redis.Interpolated.CachePolicy.RefreshAfter.init -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.EndRefresh(in StackExchange.Redis.Interpolated.RespRequest frame, int database) -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Refreshes.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryGet(in StackExchange.Redis.Interpolated.RespRequest frame, int database, long maxAgeTicks, out StackExchange.Redis.Interpolated.RespPayload? payload, out bool shouldRefresh) -> bool -[SER010]StackExchange.Redis.Interpolated.RespClientCache.TryBeginRefresh(in StackExchange.Redis.Interpolated.RespRequest request, int database, out StackExchange.Redis.Interpolated.RespClientCache.RespFill fill) -> bool -[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.get -> System.TimeSpan -[SER010]StackExchange.Redis.Interpolated.CachePolicy.InvalidationGracePeriod.init -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.OnLocalWrite(System.ReadOnlySpan key) -> bool -[SER010]StackExchange.Redis.Interpolated.RespClientCache.ServedStale.get -> long -[SER010]StackExchange.Redis.Interpolated.RespCacheConnectionExtensions -[SER010]static StackExchange.Redis.Interpolated.RespCacheConnectionExtensions.FlushOnDisconnect(this StackExchange.Redis.Interpolated.RespClientCache! cache, StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.IDisposable! -[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.get -> StackExchange.Redis.Interpolated.CacheOptions? -[SER010]StackExchange.Redis.ConfigurationOptions.ClientCache.set -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedNotTracked.get -> long -[SER010]StackExchange.Redis.Interpolated.CacheOptions -[SER010]StackExchange.Redis.Interpolated.CacheOptions.CacheOptions() -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.get -> StackExchange.Redis.Interpolated.CachePolicy! -[SER010]StackExchange.Redis.Interpolated.CacheOptions.DefaultPolicy.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.get -> bool -[SER010]StackExchange.Redis.Interpolated.CacheOptions.Enabled.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.get -> System.Collections.Generic.IReadOnlyList! -[SER010]StackExchange.Redis.Interpolated.CacheOptions.Prefixes.init -> void -[SER010]static StackExchange.Redis.Interpolated.CacheOptions.Default.get -> StackExchange.Redis.Interpolated.CacheOptions! -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Options.get -> StackExchange.Redis.Interpolated.CacheOptions! -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.get -> int? -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxPayloadBytes.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.get -> System.TimeSpan -[SER010]StackExchange.Redis.Interpolated.CacheOptions.SweepInterval.init -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.RefusedTooLarge.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.SweepIfDue() -> int -[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.get -> StackExchange.Redis.Interpolated.CacheTrackingMode -[SER010]StackExchange.Redis.Interpolated.CacheOptions.TrackingMode.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode -[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.Broadcast = 0 -> StackExchange.Redis.Interpolated.CacheTrackingMode -[SER010]StackExchange.Redis.Interpolated.CacheTrackingMode.PerKey = 1 -> StackExchange.Redis.Interpolated.CacheTrackingMode -[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.get -> int -[SER010]StackExchange.Redis.Interpolated.CacheOptions.EvictionSampleSize.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.get -> long? -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxBytes.init -> void -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.get -> int? -[SER010]StackExchange.Redis.Interpolated.CacheOptions.MaxEntries.init -> void -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Bytes.get -> long -[SER010]StackExchange.Redis.Interpolated.RespClientCache.Evicted.get -> long -[SER010]static StackExchange.Redis.Interpolated.RespHandlers.ValueLease.get -> StackExchange.Redis.Interpolated.IRespHandler!>! diff --git a/src/StackExchange.Redis/ReadOnlyLease.cs b/src/StackExchange.Redis/ReadOnlyLease.cs index c44660147..45bcf4679 100644 --- a/src/StackExchange.Redis/ReadOnlyLease.cs +++ b/src/StackExchange.Redis/ReadOnlyLease.cs @@ -84,6 +84,20 @@ internal static ReadOnlyLease Rent(int length, MemoryPool? pool, out Span< return new ReadOnlyLease(array, 0, length); } + /// + /// Take ownership of an array that has already been rented from + /// , of which only the first elements are live. + /// + /// The rented array; this lease returns it on disposal. + /// How many elements are actually populated. + /// + /// For a parser that already rents - ParseArray(allowOversized: true) is the case this exists + /// for - so its result becomes a lease without a copy. The caller must not keep using the array + /// afterwards: this lease is now the owner, and will hand it back. + /// + internal static ReadOnlyLease Adopt(T[] pooled, int length) + => length == 0 ? Empty : new ReadOnlyLease(pooled, 0, length); + /// /// Create a lease that shares an existing buffer rather than copying out of it. /// diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs index bad769d02..2099947fb 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceHashesTests.cs @@ -72,9 +72,9 @@ public async Task NoFieldsMeansNoCommand() { var (ctx, exec) = Target(); - Assert.Empty(await ctx.Hashes.Get("k", ReadOnlySpan.Empty)); + Assert.Empty((await ctx.Hashes.Get("k", ReadOnlySpan.Empty)).Span.ToArray()); Assert.Equal(0, await ctx.Hashes.Delete("k", ReadOnlySpan.Empty)); - Assert.Empty(await ctx.Hashes.Persist("k", ReadOnlySpan.Empty)); + Assert.Empty((await ctx.Hashes.Persist("k", ReadOnlySpan.Empty)).Span.ToArray()); await ctx.Hashes.Set("k", ReadOnlySpan.Empty); Assert.Empty(exec.Sent); @@ -139,8 +139,8 @@ public async Task GetAllReadsBothPairShapes() var (jagged, _) = Target("*2\r\n*2\r\n$2\r\nf1\r\n$2\r\nv1\r\n*2\r\n$2\r\nf2\r\n$2\r\nv2\r\n"); HashEntry[] expected = [new("f1", "v1"), new("f2", "v2")]; - Assert.Equal(expected, await interleaved.Hashes.GetAll("k")); - Assert.Equal(expected, await jagged.Hashes.GetAll("k")); + Assert.Equal(expected, (await interleaved.Hashes.GetAll("k")).Span.ToArray()); + Assert.Equal(expected, (await jagged.Hashes.GetAll("k")).Span.ToArray()); } [Fact] @@ -310,10 +310,10 @@ public async Task ExpireResultsComeBackAsTheirEnum() var (ctx, _) = Target("*3\r\n:1\r\n:0\r\n:-2\r\n"); RedisValue[] fields = ["a", "b", "c"]; - var results = await ctx.Hashes.Expire("k", fields, TimeSpan.FromSeconds(60)); + using var results = await ctx.Hashes.Expire("k", fields, TimeSpan.FromSeconds(60)); Assert.Equal( new[] { ExpireResult.Success, ExpireResult.ConditionNotMet, ExpireResult.NoSuchField }, - results); + results.Span.ToArray()); } } diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs index b01d0562d..22b860da5 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceSetsTests.cs @@ -74,7 +74,7 @@ public async Task NothingToDoMeansNoCommand() Assert.Equal(0, await ctx.Sets.Add("k", ReadOnlySpan.Empty)); Assert.Equal(0, await ctx.Sets.Remove("k", ReadOnlySpan.Empty)); - Assert.Empty(await ctx.Sets.Contains("k", ReadOnlySpan.Empty)); + Assert.Empty((await ctx.Sets.Contains("k", ReadOnlySpan.Empty)).Span.ToArray()); Assert.Empty(exec.Sent); } @@ -86,7 +86,7 @@ public async Task PopOfNoneRemovesNothing() // the old surface sends a bare SPOP for a count of zero, which removes ONE. Diverging here is // deliberate: "pop none" quietly popping one is discovered in production, not in review. - Assert.Empty(await ctx.Sets.Pop("k", 0L)); + Assert.Empty((await ctx.Sets.Pop("k", 0L)).Span.ToArray()); Assert.Empty(exec.Sent); await ctx.Sets.Pop("k", 2); @@ -99,7 +99,7 @@ public async Task ContainsHasASingularAndAPluralCommand() var (ctx, exec) = Target(":1\r\n", "*2\r\n:1\r\n:0\r\n"); Assert.True(await ctx.Sets.Contains("k", "a")); - Assert.Equal(new[] { true, false }, await ctx.Sets.Contains("k", ["a", "b"])); + Assert.Equal(new[] { true, false }, (await ctx.Sets.Contains("k", ["a", "b"])).Span.ToArray()); Assert.Equal( new[] { "*3|$9|SISMEMBER|$1|k|$1|a|", "*4|$10|SMISMEMBER|$1|k|$1|a|$1|b|" }, diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs index 395e2e3d4..a81909d42 100644 --- a/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs +++ b/tests/StackExchange.Redis.Tests/RespSurfaceSortedSetsTests.cs @@ -299,7 +299,7 @@ public async Task PoppingNoneAsksNobody() // `count: 0` rather than a bare 0, because Order is an enum and so a literal zero is ambiguous // between the two overloads - a wart this surface inherits from the pair it replaces - Assert.Empty(await ctx.SortedSets.Pop("k", count: 0)); + Assert.Empty((await ctx.SortedSets.Pop("k", count: 0)).Span.ToArray()); Assert.Empty(exec.Sent); } @@ -329,7 +329,7 @@ public async Task ScoresComeBackNullableBecauseAMemberMayBeAbsent() var (ctx, exec) = Target("*3\r\n$1\r\n1\r\n$-1\r\n$1\r\n3\r\n"); RedisValue[] members = ["a", "b", "c"]; - Assert.Equal(new double?[] { 1, null, 3 }, await ctx.SortedSets.Scores("k", members)); + Assert.Equal(new double?[] { 1, null, 3 }, (await ctx.SortedSets.Scores("k", members)).Span.ToArray()); Assert.Equal("*5|$7|ZMSCORE|$1|k|$1|a|$1|b|$1|c|", Assert.Single(exec.Sent)); } From 28d7fa3d7880b97ca4b3ab185dc2260688faba5d Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 05:57:42 +0100 Subject: [PATCH 147/360] Cacheability exclusions: say it at the command, not in a table Client-side caching is opt-out - a read-only retry category plus a key is enough - which is right for the overwhelming majority and wrong for a handful. The handful is the dangerous part, because a command wrongly cached serves a wrong answer forever with no error and no log. NeverCached() marks them at the one place that knows, the command definition, rather than in a table the cache consults and has to keep in step. It reuses the NoClientCache bit deliberately: same effect, and a caller cannot unset what the surface has or-ed in. Two reasons to land there. Non-determinism - SRANDMEMBER, HRANDFIELD and ZRANDMEMBER are asked because the answer should differ each time, so caching defeats the command and nothing would ever invalidate it, since nothing changed. Time-variance - HPTTL counts down, so it is stale the instant it is stored and no correction is coming, because expiry is announced to nobody. HPEXPIRETIME is deliberately left cacheable: an absolute instant does not drift, and only becomes wrong when the field expires - the same exposure every cached read of a volatile key already has, and what the entry lifetime bounds. Recorded as a judgement, and queued for a second opinion next to DUMP. The SCAN family needed nothing: cursors were never brought to this surface. Tested through the cache rather than by reading flags back, with a control - an ordinary SMEMBERS of the same shape must still cache, or the whole file would pass equally well with caching broken outright. --- design/interpolated-resp-writer.queue.md | 26 ++-- .../Enums/CommandFlags.Category.cs | 25 ++++ .../Interpolated/RespSurface.Hashes.cs | 30 ++-- .../Interpolated/RespSurface.Sets.cs | 6 +- .../Interpolated/RespSurface.SortedSets.cs | 10 +- .../RespCacheExclusionTests.cs | 134 ++++++++++++++++++ 6 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RespCacheExclusionTests.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 6261d350d..b0d80bfc0 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -11,17 +11,11 @@ a line saying why, because "we decided not to" is worth as much as "we did". ## Now -- [ ] **Cacheability metadata for the seven exclusions** (§6.9). `SRANDMEMBER`, `HRANDFIELD`, - `ZRANDMEMBER`, the `*SCAN` family, `TTL`/`PTTL`, `TOUCH`, `PFCOUNT` all sit in - `CommandRetryReadOnly` alongside `GET` and would be cached wrongly today. A correctness hole, and - small. `DUMP` wants a second opinion. - **Narrowed, not closed, by `CachePolicy.Prefixes`.** These are *command*-shaped defects and prefixes - are a *key-space* opt-in, so a non-deterministic command on a declared key is still cached wrongly. - What changed is the blast radius: nothing is cached unless its key space was positively declared, so - a caller who scopes tightly is no longer exposed on key families they never meant to cache at all. - It does largely answer the module worry below for free — `FT.*` names indexes, and an index name is - not usually in a data-key prefix list, so those replies now fall out as `RefusedNotTracked` rather - than being cached with nothing to invalidate them. +- [ ] **Two cacheability calls wanting a second opinion.** `DUMP` (a serialised payload - stable for a + given value, and invalidated like any other read, so arguably fine) and `HPEXPIRETIME`, which is + currently left *cacheable* on the grounds that an absolute instant does not drift, where `HPTTL` + counts down and is stale the moment it is stored. The rest of the exclusion list is done. `TOUCH` and + `PFCOUNT` are not on the new surface yet; when the `Keys` group lands, `TOUCH` needs `.NeverCached()`. ## Next @@ -99,6 +93,13 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [ ] **More command groups**, in `RespSurface..cs` + `TransitionalDatabase..cs` pairs. Mechanical now; `Strings` and `Bitmaps` are the worked examples. SER352 counts what is left. +- [ ] **A `Keys` command group** (`RespSurface.Keys.cs`), covering the old `Key*` prefix: `Delete`, + `Exists`, `Expire`, `TimeToLive`, `Persist`, `Rename`, `Touch`, `Random`, `Type`. Named `Keys` + rather than `Keyspace` to match the other groups (`Strings`, `Hashes`, `Sets`, `SortedSets` are all + plural-of-the-thing) and because `Keyspace` collides with `KeyspaceIsolation`, which means something + quite different. Note `DbSize` is `IServer.DatabaseSize`, so it belongs to the `IServer` context + rather than here. `Touch` and the relative-TTL readers need `.NeverCached()`. + ## Later / decide first - [ ] **Per-context `CachePolicy` override** (`WithCachePolicy`). The other half of the options/policy @@ -153,7 +154,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Measured invalidation timing against a real server (6.13) — `eddb3b5f` - [x] Wire `OnLocalWrite`: a write tells the cache before it is sent — `b21ad97a` - [x] A bulk write invalidates its own arguments, not the whole cache — `bbb91af6` -- [x] Arrays off the new API: 28 returns become `ReadOnlyLease`, with internal `...Array` siblings — this change +- [x] Arrays off the new API: 28 returns become `ReadOnlyLease`, with internal `...Array` siblings — `9625bde1` +- [x] Cacheability exclusions: `.NeverCached()` on the random readers and `HPTTL` — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` - [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs index 9e0d5a53c..d9457d34b 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs @@ -56,6 +56,31 @@ internal static CommandFlags WithScanCursorCategory(this CommandFlags flags, in _ => CommandFlags.CommandRetryWriteChecked, }; + /// + /// Mark a command as one whose reply must never be cached, whatever its retry category says. + /// + /// + /// + /// Client-side caching is opt-out: a command that declares a read-only retry category and names + /// a key is cacheable by default, which is right for the overwhelming majority and wrong for a handful. + /// This is how those few say so, at the one place that knows - the command definition - rather than in + /// a table the cache has to consult and keep in step. + /// + /// + /// Two reasons a command lands here. Non-determinism: SRANDMEMBER, HRANDFIELD and + /// ZRANDMEMBER are asked precisely because the answer should differ each time, so a cache would + /// defeat the command rather than accelerate it - and nothing would ever invalidate it, because nothing + /// changed. Time-variance: a reply that counts down, such as HPTTL, is already wrong by + /// the time it is stored, and no invalidation is coming because the server announces expiry to nobody + /// (design notes 6.13). + /// + /// + /// It uses the same bit as the caller-facing deliberately: the + /// effect is identical, and a caller cannot unset what the command surface has already or-ed in. + /// + /// + internal static CommandFlags NeverCached(this CommandFlags flags) => flags | CommandFlags.NoClientCache; + internal static CommandFlags WithDefaultCategory(this CommandFlags flags, RedisCommand command) { if ((flags & Message.MaskRetryCategory) is 0) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs index 5abbfef03..b285fa35e 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -183,7 +183,7 @@ public static ValueTask Exists(this in RespHashes hashes, RedisKey key, Re /// Command flags. public static ValueTask RandomField(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( - $"{RedisCommand.HRANDFIELD}{key}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + $"{RedisCommand.HRANDFIELD}{key}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD).NeverCached()); /// HRANDFIELD with a count. /// The hash command group. @@ -192,7 +192,7 @@ public static ValueTask RandomField(this in RespHashes hashes, Redis /// Command flags. public static ValueTask> RandomFields(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync>( - $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD).NeverCached()); /// RandomFields, as an array, for the old IDatabase surface. /// @@ -202,7 +202,7 @@ public static ValueTask> RandomFields(this in RespHash /// internal static ValueTask RandomFieldsArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( - $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + $"{RedisCommand.HRANDFIELD}{key}{count}", flags.WithDefaultCategory(RedisCommand.HRANDFIELD).NeverCached()); /// HRANDFIELD ... WITHVALUES. /// The hash command group. @@ -212,7 +212,7 @@ internal static ValueTask RandomFieldsArray(this in RespHashes has public static ValueTask> RandomFieldsWithValues(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync>( $"{RedisCommand.HRANDFIELD}{key}{count}{RespLiterals.WithValues}", - flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + flags.WithDefaultCategory(RedisCommand.HRANDFIELD).NeverCached()); /// RandomFieldsWithValues, as an array, for the old IDatabase surface. /// @@ -223,7 +223,7 @@ public static ValueTask> RandomFieldsWithValues(this in internal static ValueTask RandomFieldsWithValuesArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( $"{RedisCommand.HRANDFIELD}{key}{count}{RespLiterals.WithValues}", - flags.WithDefaultCategory(RedisCommand.HRANDFIELD)); + flags.WithDefaultCategory(RedisCommand.HRANDFIELD).NeverCached()); // ---- writes ------------------------------------------------------------------------------------ @@ -429,7 +429,7 @@ public static ValueTask> GetTimeToLive(this in RespHashes ha ? new ValueTask>(ReadOnlyLease.Empty) : hashes.Context.SendAsync>( $"{RedisCommand.HPTTL}{key}{RespLiterals.Fields}{fields.Length}{fields}", - flags.WithDefaultCategory(RedisCommand.HPTTL)); + flags.WithDefaultCategory(RedisCommand.HPTTL).NeverCached()); /// GetTimeToLive, as an array, for the old IDatabase surface. /// @@ -442,14 +442,28 @@ internal static ValueTask GetTimeToLiveArray(this in RespHashes hashes, ? new ValueTask(Array.Empty()) : hashes.Context.SendAsync( $"{RedisCommand.HPTTL}{key}{RespLiterals.Fields}{fields.Length}{fields}", - flags.WithDefaultCategory(RedisCommand.HPTTL)); + flags.WithDefaultCategory(RedisCommand.HPTTL).NeverCached()); /// HPEXPIRETIME: when the fields expire, as a Unix time in milliseconds. /// The hash command group. /// The key to read. /// The fields to ask about. /// Command flags. - /// + /// + /// + /// + /// Cacheable, where GetTimeToLive is not, and the difference is absolute versus + /// relative rather than a slip. HPTTL counts down: the answer is different a millisecond + /// later, so it is stale the instant it is stored and nothing will ever say so, because expiry is + /// announced to nobody. This returns a fixed instant, which does not drift - it only becomes wrong + /// once the field actually expires, which is the same exposure every cached read of a volatile key + /// already has, and is what is there to bound. + /// + /// + /// Recorded as a judgement rather than an obvious call: it is on the queue for a second opinion, + /// next to DUMP. + /// + /// public static ValueTask> GetExpireDateTime(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty ? new ValueTask>(ReadOnlyLease.Empty) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs index 9d5adbaee..e4f4c6bda 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs @@ -191,7 +191,7 @@ internal static ValueTask PopArray(this in RespSets sets, RedisKey /// Command flags. public static ValueTask RandomMember(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( - $"{RedisCommand.SRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + $"{RedisCommand.SRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER).NeverCached()); /// SRANDMEMBER with a count. /// The set command group. @@ -200,7 +200,7 @@ public static ValueTask RandomMember(this in RespSets sets, RedisKey /// Command flags. public static ValueTask> RandomMembers(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync>( - $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER).NeverCached()); /// RandomMembers, as an array, for the old IDatabase surface. /// @@ -210,7 +210,7 @@ public static ValueTask> RandomMembers(this in RespSet /// internal static ValueTask RandomMembersArray(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( - $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER)); + $"{RedisCommand.SRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.SRANDMEMBER).NeverCached()); /// SUNION/SINTER/SDIFF. /// The set command group. diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs index a31704d66..65dd31ce4 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs @@ -268,7 +268,7 @@ public static ValueTask LengthByValue( /// Command flags. public static ValueTask RandomMember(this in RespSortedSets sortedSets, RedisKey key, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( - $"{RedisCommand.ZRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + $"{RedisCommand.ZRANDMEMBER}{key}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER).NeverCached()); /// ZRANDMEMBER with a count. /// The sorted-set command group. @@ -277,7 +277,7 @@ public static ValueTask RandomMember(this in RespSortedSets sortedSe /// Command flags. public static ValueTask> RandomMembers(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync>( - $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER).NeverCached()); /// RandomMembers, as an array, for the old IDatabase surface. /// @@ -287,7 +287,7 @@ public static ValueTask> RandomMembers(this in RespSor /// internal static ValueTask RandomMembersArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( - $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + $"{RedisCommand.ZRANDMEMBER}{key}{count}", flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER).NeverCached()); /// ZRANDMEMBER ... WITHSCORES. /// The sorted-set command group. @@ -297,7 +297,7 @@ internal static ValueTask RandomMembersArray(this in RespSortedSet public static ValueTask> RandomMembersWithScores(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync>( $"{RedisCommand.ZRANDMEMBER}{key}{count}{RespLiterals.WithScores}", - flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER).NeverCached()); /// RandomMembersWithScores, as an array, for the old IDatabase surface. /// @@ -308,7 +308,7 @@ public static ValueTask> RandomMembersWithScores(t internal static ValueTask RandomMembersWithScoresArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( $"{RedisCommand.ZRANDMEMBER}{key}{count}{RespLiterals.WithScores}", - flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER)); + flags.WithDefaultCategory(RedisCommand.ZRANDMEMBER).NeverCached()); // ---- ranges ------------------------------------------------------------------------------------ diff --git a/tests/StackExchange.Redis.Tests/RespCacheExclusionTests.cs b/tests/StackExchange.Redis.Tests/RespCacheExclusionTests.cs new file mode 100644 index 000000000..8a694f19f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespCacheExclusionTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Commands that look cacheable and are not. +/// +/// +/// +/// Client-side caching is opt-out: declaring a read-only retry category and naming a key is enough +/// to be cached. That is right for the overwhelming majority and wrong for a handful, and the handful is +/// the dangerous part - a command cached that should not have been serves a wrong answer forever, with no +/// error and no log. These are the ones that have to say so. +/// +/// +/// Asserted through the cache rather than by reading flags back, because the flag is a means: what +/// matters is that nothing is stored and nothing is served, whatever mechanism gets us there. +/// +/// +public class RespCacheExclusionTests +{ + private sealed class FakeExecutor(string reply) : IRespExecutor + { + public int Sent { get; private set; } + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent++; + return RespPayload.Create(Encoding.UTF8.GetBytes(reply)); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + /// Run the same command twice and report whether the second one was served locally. + private static async Task<(bool Cached, long Refused)> RunTwice( + string reply, + Func command) + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor(reply); + var context = new RespContext().WithExecutor(executor).WithCache(cache); + + await command(context); + await command(context); + + return (executor.Sent == 1, cache.RefusedByFlags); + } + + /// + /// The non-deterministic readers are never cached. + /// + /// + /// These are asked precisely because the answer should differ each time, so caching would defeat + /// the command rather than accelerate it - and nothing would ever invalidate it, because nothing + /// changed. They sit in the same retry category as GET, which is why they have to opt out explicitly. + /// + [Fact] + public async Task RandomMemberCommandsAreNeverCached() + { + var cases = new (string Name, string Reply, Func Run)[] + { + ("SRANDMEMBER", "$1\r\na\r\n", static c => Discard(c.Sets.RandomMember("k"))), + ("SRANDMEMBER count", "*1\r\n$1\r\na\r\n", static c => DiscardLease(c.Sets.RandomMembers("k", 2))), + ("HRANDFIELD", "$1\r\na\r\n", static c => Discard(c.Hashes.RandomField("k"))), + ("HRANDFIELD count", "*1\r\n$1\r\na\r\n", static c => DiscardLease(c.Hashes.RandomFields("k", 2))), + ("HRANDFIELD WITHVALUES", "*2\r\n$1\r\na\r\n$1\r\nb\r\n", static c => DiscardLease(c.Hashes.RandomFieldsWithValues("k", 2))), + ("ZRANDMEMBER", "$1\r\na\r\n", static c => Discard(c.SortedSets.RandomMember("k"))), + ("ZRANDMEMBER count", "*1\r\n$1\r\na\r\n", static c => DiscardLease(c.SortedSets.RandomMembers("k", 2))), + ("ZRANDMEMBER WITHSCORES", "*2\r\n$1\r\na\r\n$1\r\n1\r\n", static c => DiscardLease(c.SortedSets.RandomMembersWithScores("k", 2))), + }; + + foreach (var (name, reply, run) in cases) + { + var (cached, refused) = await RunTwice(reply, run); + Assert.False(cached, $"{name} was served from cache"); + Assert.True(refused > 0, $"{name} was not refused by flags"); + } + } + + /// + /// A reply that counts down is never cached; one that names a fixed instant may be. + /// + /// + /// HPTTL is different a millisecond later, so it is stale the moment it is stored - and no + /// correction is coming, because the server announces expiry to nobody (measured; design notes 6.13). + /// HPEXPIRETIME returns an instant, which does not drift; it only becomes wrong once the field + /// actually expires, which is the exposure every cached read of a volatile key already has and which + /// the entry lifetime exists to bound. + /// + [Fact] + public async Task RelativeExpiryIsNotCachedButAbsoluteIs() + { + var (ttlCached, ttlRefused) = await RunTwice( + "*1\r\n:1000\r\n", + static c => DiscardLease(c.Hashes.GetTimeToLive("k", ["f"]))); + Assert.False(ttlCached, "HPTTL was served from cache"); + Assert.True(ttlRefused > 0); + + var (whenCached, _) = await RunTwice( + "*1\r\n:1700000000000\r\n", + static c => DiscardLease(c.Hashes.GetExpireDateTime("k", ["f"]))); + Assert.True(whenCached, "HPEXPIRETIME should be cacheable: an instant does not drift"); + } + + /// An ordinary read of the same shape still caches - so the tests above prove a rule, not a bug. + /// + /// Without this the assertions above would pass just as well if caching were broken outright, which is + /// the failure mode a suite of "is not cached" tests invites. + /// + [Fact] + public async Task AnOrdinaryReadOfTheSameShapeIsStillCached() + { + var (cached, refused) = await RunTwice( + "*1\r\n$1\r\na\r\n", + static c => DiscardLease(c.Sets.Members("k"))); + + Assert.True(cached, "SMEMBERS should be cached"); + Assert.Equal(0, refused); + } + + private static async ValueTask Discard(ValueTask pending) => await pending; + + private static async ValueTask DiscardLease(ValueTask> pending) => (await pending).Dispose(); +} From 71422d54e59fc11b6a14684b55e9082ee3638146 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 06:07:10 +0100 Subject: [PATCH 148/360] The Keys command group The old Key* prefix existed because one interface had to hold everything; here the group is the receiver, so Keys.Delete says the same thing. Named Keys rather than Keyspace: it matches the other groups, which are all plural-of-the-thing, and Keyspace already means prefixing in this library. Delete/Unlink/Exists/Expire/Persist/TimeToLive/ExpireTime/Rename/Touch/Random/ Type/Copy/Move/Dump, plus the four handlers they needed - RedisKey, RedisType, TimeSpan? and DateTime?. RedisType parses through the generated token table rather than Enum.TryParse, because the wire spellings are not the member names: a sorted set is zset. Three commands say .NeverCached(). TOUCH exists for its side effect, so answering it locally skips the only thing it was called for. RANDOMKEY is meant to differ each time AND names no key, so either reason alone would do. PTTL counts down. PEXPIRETIME is deliberately left cacheable next to it - an instant does not drift - and is the control in the test, so the file cannot pass with caching broken. COPY's optional operands are holes rather than branches, via an IRespArgument that writes two tokens or none: a fragment carries a count the writer takes on trust, whereas writing through the handler cannot miscount. One interpolated string covers all four shapes. DBSIZE is not here: it is an IServer command. The OBJECT family is deferred for the reason the scan cursors were - a different shape, better done together. Separately: CountKeys depended on two FLUSHDBs that were fire-and-forget on a connection disposed on the next line, with the writes and counts on a different connection. GetDedicatedDB restarts its counter every process, so the same index belongs to whichever test drew it that run - a dropped flush inherits another test's keys. Now awaited. --- design/interpolated-resp-writer.queue.md | 13 +- .../Interpolated/RespLiterals.cs | 8 + .../Interpolated/RespSurface.Hashes.cs | 2 +- .../Interpolated/RespSurface.Keys.cs | 329 ++++++++++++++++++ .../Interpolated/RespSurface.cs | 50 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 26 ++ .../DatabaseTests.cs | 9 +- .../RespSurfaceKeysTests.cs | 201 +++++++++++ 8 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 src/StackExchange.Redis/Interpolated/RespSurface.Keys.cs create mode 100644 tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index b0d80bfc0..439c395de 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -93,12 +93,10 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [ ] **More command groups**, in `RespSurface..cs` + `TransitionalDatabase..cs` pairs. Mechanical now; `Strings` and `Bitmaps` are the worked examples. SER352 counts what is left. -- [ ] **A `Keys` command group** (`RespSurface.Keys.cs`), covering the old `Key*` prefix: `Delete`, - `Exists`, `Expire`, `TimeToLive`, `Persist`, `Rename`, `Touch`, `Random`, `Type`. Named `Keys` - rather than `Keyspace` to match the other groups (`Strings`, `Hashes`, `Sets`, `SortedSets` are all - plural-of-the-thing) and because `Keyspace` collides with `KeyspaceIsolation`, which means something - quite different. Note `DbSize` is `IServer.DatabaseSize`, so it belongs to the `IServer` context - rather than here. `Touch` and the relative-TTL readers need `.NeverCached()`. +- [ ] **The `OBJECT` family and `DBSIZE`.** Deferred out of the `Keys` group: `OBJECT ENCODING/REFCOUNT/ + FREQ/IDLETIME` are a different command shape, better done together, and `IDLETIME` will want + `.NeverCached()` for the same reason `PTTL` does. `DBSIZE` is an `IServer` command and belongs to + that context, not to `Keys`. ## Later / decide first @@ -155,7 +153,8 @@ a line saying why, because "we decided not to" is worth as much as "we did". - [x] Wire `OnLocalWrite`: a write tells the cache before it is sent — `b21ad97a` - [x] A bulk write invalidates its own arguments, not the whole cache — `bbb91af6` - [x] Arrays off the new API: 28 returns become `ReadOnlyLease`, with internal `...Array` siblings — `9625bde1` -- [x] Cacheability exclusions: `.NeverCached()` on the random readers and `HPTTL` — this change +- [x] Cacheability exclusions: `.NeverCached()` on the random readers and `HPTTL` — `28d7fa3d` +- [x] The `Keys` command group, and awaiting the flush `CountKeys` depended on — this change - [x] `CacheTrackingMode`: broadcast vs per-key, with prefixes validated against it — `728e9102` - [x] Byte and entry quotas, with sampled eviction — `87d5afa2` - [x] `MaxPayloadBytes`, and a sweep that actually runs: `SweepInterval` + the multiplexer heartbeat, and diff --git a/src/StackExchange.Redis/Interpolated/RespLiterals.cs b/src/StackExchange.Redis/Interpolated/RespLiterals.cs index afd7fe2bd..4db9889b7 100644 --- a/src/StackExchange.Redis/Interpolated/RespLiterals.cs +++ b/src/StackExchange.Redis/Interpolated/RespLiterals.cs @@ -200,5 +200,13 @@ internal static partial class RespLiterals /// [Resp] internal static partial RespFragment Lt { get; } + + /// The DB operand of COPY. + [Resp] + internal static partial RespFragment Db { get; } + + /// The REPLACE operand of COPY. + [Resp] + internal static partial RespFragment Replace { get; } } } diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs index b285fa35e..28cea77c8 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -348,7 +348,7 @@ public static ValueTask Increment(this in RespHashes hashes, RedisKey ke /// /// /// and have no spelling: the - /// first is not a deadline and the second is , which is a different command + /// first is not a deadline and the second is Persist, which is a different command /// with a different reply. An absent expiry is likewise not a request. /// /// diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Keys.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Keys.cs new file mode 100644 index 000000000..3cbb5b0f3 --- /dev/null +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Keys.cs @@ -0,0 +1,329 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Interpolated +{ + /// + /// EXPERIMENTAL SPIKE. The key-command group: target.Keys.Delete(...). + /// + /// + /// + /// The old surface spells these with a Key prefix - KeyDelete, KeyExpire, + /// KeyTimeToLive - which was the only way to group them when everything hung off one interface. + /// Here the group is the receiver, so the prefix goes and Keys.Delete says the same thing. + /// + /// + /// Named Keys rather than Keyspace: it matches the other groups, which are all + /// plural-of-the-thing, and Keyspace already means something else in this library - see the + /// KeyspaceIsolation namespace, which is about prefixing rather than about key commands. + /// + /// + /// DBSIZE is not here despite looking like it belongs: it is an IServer command, + /// not a database one, and it lands in that context when it exists. The OBJECT family + /// (ENCODING, REFCOUNT, FREQ, IDLETIME) is deferred for the same reason + /// the scan cursors were: a different command shape, better done together. + /// + /// + [Experimental(Experiments.InterpolatedWriter, UrlFormat = Experiments.UrlFormat)] + public readonly struct RespKeys + { + private readonly RespContext _context; + + /// Group the key commands of a context. + /// The context to send through. + public RespKeys(in RespContext context) => _context = context; + + /// The underlying context. + public RespContext Context => _context; + } + + public static partial class RespSurface + { + extension(IRespTarget target) + { + /// The key commands. + public RespKeys Keys => new(target.Context); + } + + extension(in RespContext context) + { + /// The key commands. + public RespKeys Keys => new(context); + } + + /// DEL: remove a key, reporting whether it was there. + /// The key command group. + /// The key to remove. + /// Command flags. + public static ValueTask Delete(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.DEL}{key}", flags.WithDefaultCategory(RedisCommand.DEL)); + + /// DEL with several keys; the reply is how many existed. + /// The key command group. + /// The keys to remove. + /// Command flags. + public static ValueTask Delete(this in RespKeys keys, ReadOnlySpan targets, CommandFlags flags = CommandFlags.None) + => targets.IsEmpty + ? new ValueTask(0L) + : keys.Context.SendAsync( + $"{RedisCommand.DEL}{targets}", flags.WithDefaultCategory(RedisCommand.DEL)); + + /// UNLINK: as DEL, but the reclaim happens on another thread. + /// The key command group. + /// The key to remove. + /// Command flags. + /// + /// A separate method rather than a flag on Delete: the difference is visible to the server + /// operator rather than to the caller, and hiding it behind an option would make the choice + /// invisible at the call site, which is where it is made. + /// + public static ValueTask Unlink(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.UNLINK}{key}", flags.WithDefaultCategory(RedisCommand.UNLINK)); + + /// + /// The key command group. + /// The keys to remove. + /// Command flags. + public static ValueTask Unlink(this in RespKeys keys, ReadOnlySpan targets, CommandFlags flags = CommandFlags.None) + => targets.IsEmpty + ? new ValueTask(0L) + : keys.Context.SendAsync( + $"{RedisCommand.UNLINK}{targets}", flags.WithDefaultCategory(RedisCommand.UNLINK)); + + /// EXISTS. + /// The key command group. + /// The key to test. + /// Command flags. + public static ValueTask Exists(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.EXISTS}{key}", flags.WithDefaultCategory(RedisCommand.EXISTS)); + + /// EXISTS with several keys; the reply counts them, including duplicates. + /// The key command group. + /// The keys to test. + /// Command flags. + public static ValueTask Exists(this in RespKeys keys, ReadOnlySpan targets, CommandFlags flags = CommandFlags.None) + => targets.IsEmpty + ? new ValueTask(0L) + : keys.Context.SendAsync( + $"{RedisCommand.EXISTS}{targets}", flags.WithDefaultCategory(RedisCommand.EXISTS)); + + /// EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT, chosen from the expiry. + /// The key command group. + /// The key to set a deadline on. + /// When it should expire. + /// The condition under which the deadline applies. + /// Command flags. + /// + /// One method over four commands, as in the hash group: whether the deadline is absolute or + /// relative, and whether it is in seconds or milliseconds, are properties of the + /// rather than decisions the caller should have to spell as a command name. + /// Persist is deliberately not reachable from here - it is a different command with a + /// different reply, and is rejected rather than silently rerouted. + /// + public static ValueTask Expire( + this in RespKeys keys, + RedisKey key, + Expiration expiry, + ExpireWhen when = ExpireWhen.Always, + CommandFlags flags = CommandFlags.None) + { + var command = SelectKeyExpireCommand(expiry); + return keys.Context.SendAsync( + $"{command}{key}{expiry.Value}{AsFragment(when)}", + flags.WithRetryCategory(when.AsRetryCategory()).WithDefaultCategory(command)); + } + + /// PERSIST: remove any deadline. + /// The key command group. + /// The key to make permanent. + /// Command flags. + public static ValueTask Persist(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.PERSIST}{key}", flags.WithDefaultCategory(RedisCommand.PERSIST)); + + /// PTTL: how long the key has left, or null if it has no deadline. + /// The key command group. + /// The key to ask about. + /// Command flags. + /// + /// Never cached, and it is the clearest case in the library: the answer counts down, so it is + /// already wrong by the time it is stored, and no correction is coming because the server announces + /// expiry to nobody. Contrast , which names an instant and does not drift. + /// + /// "No such key" and "no deadline" both read as null - the caller asked how long is left, and the + /// answer is "no deadline" either way. + /// distinguishes them. + /// + /// + public static ValueTask TimeToLive(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.PTTL}{key}", flags.WithDefaultCategory(RedisCommand.PTTL).NeverCached()); + + /// PEXPIRETIME: when the key expires, or null if it has no deadline. + /// The key command group. + /// The key to ask about. + /// Command flags. + /// + /// Cacheable where is not: an instant does not drift, so this only becomes + /// wrong once the key actually expires - the same exposure every cached read of a volatile key + /// already has, and what exists to bound. + /// + public static ValueTask ExpireTime(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.PEXPIRETIME}{key}", flags.WithDefaultCategory(RedisCommand.PEXPIRETIME)); + + /// RENAME, or RENAMENX when the destination must not exist. + /// The key command group. + /// The key to rename. + /// The name to give it. + /// Whether an existing destination may be replaced. + /// Command flags. + public static ValueTask Rename( + this in RespKeys keys, + RedisKey key, + RedisKey newKey, + When when = When.Always, + CommandFlags flags = CommandFlags.None) + { + var command = when switch + { + When.Always => RedisCommand.RENAME, + When.NotExists => RedisCommand.RENAMENX, + _ => throw new ArgumentOutOfRangeException(nameof(when), when, "RENAME has no XX form."), + }; + + return keys.Context.SendAsync( + $"{command}{key}{newKey}", flags.WithDefaultCategory(command)); + } + + /// TOUCH: mark a key as recently used, reporting whether it was there. + /// The key command group. + /// The key to touch. + /// Command flags. + /// + /// Never cached. The point of the command is the side effect on the server's idle/LRU + /// bookkeeping, so answering it locally would skip the only thing it was called for - and the reply + /// it happens to return would then be a cached statement about existence with nothing to correct it. + /// + public static ValueTask Touch(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.TOUCH}{key}", flags.WithDefaultCategory(RedisCommand.TOUCH).NeverCached()); + + /// + /// The key command group. + /// The keys to touch. + /// Command flags. + public static ValueTask Touch(this in RespKeys keys, ReadOnlySpan targets, CommandFlags flags = CommandFlags.None) + => targets.IsEmpty + ? new ValueTask(0L) + : keys.Context.SendAsync( + $"{RedisCommand.TOUCH}{targets}", flags.WithDefaultCategory(RedisCommand.TOUCH).NeverCached()); + + /// RANDOMKEY. + /// The key command group. + /// Command flags. + /// + /// Never cached, for both available reasons at once: the answer is meant to differ each time, + /// and the command names no key, so nothing could ever invalidate an entry for it. Either one alone + /// would be enough. + /// + public static ValueTask Random(this in RespKeys keys, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.RANDOMKEY}", flags.WithDefaultCategory(RedisCommand.RANDOMKEY).NeverCached()); + + /// TYPE. + /// The key command group. + /// The key to inspect. + /// Command flags. + public static ValueTask Type(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.TYPE}{key}", flags.WithDefaultCategory(RedisCommand.TYPE)); + + /// COPY. + /// The key command group. + /// The key to copy from. + /// The key to copy to. + /// The database to copy into; -1 for the current one. + /// Whether an existing destination may be overwritten. + /// Command flags. + /// + /// The two optional operands are holes rather than branches: an absent DB and an absent + /// REPLACE are zero-argument fragments, so one interpolated string covers all four shapes. + /// + public static ValueTask Copy( + this in RespKeys keys, + RedisKey source, + RedisKey destination, + int destinationDatabase = -1, + bool replace = false, + CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.COPY}{source}{destination}{new DatabaseOperand(destinationDatabase)}{(replace ? RespLiterals.Replace : default)}", + flags.WithDefaultCategory(RedisCommand.COPY)); + + /// MOVE. + /// The key command group. + /// The key to move. + /// The database to move it into. + /// Command flags. + public static ValueTask Move(this in RespKeys keys, RedisKey key, int database, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync( + $"{RedisCommand.MOVE}{key}{database}", flags.WithDefaultCategory(RedisCommand.MOVE)); + + /// DUMP: the serialised form of a key, or null if it is not there. + /// The key command group. + /// The key to serialise. + /// Command flags. + /// + /// A pooled lease rather than a byte[], like every other payload on this surface: the caller + /// almost always feeds it straight to RESTORE or to a stream, and an array would be a + /// per-call allocation nothing can reclaim. It must be disposed. + /// + public static ValueTask?> Dump(this in RespKeys keys, RedisKey key, CommandFlags flags = CommandFlags.None) + => keys.Context.SendAsync?>( + $"{RedisCommand.DUMP}{key}", flags.WithDefaultCategory(RedisCommand.DUMP)); + + /// + /// The DB n operand of COPY - two arguments, or none at all. + /// + /// + /// A rather than a fragment because it is conditional and + /// multi-token: a fragment carries a fixed blob and an argument count the writer takes on + /// trust, whereas this writes through the handler's own AppendFormatted, so the count cannot + /// disagree with what was written. Writing nothing is a legal implementation and is how the absent + /// case is spelled - which is what keeps Copy one interpolated string rather than four. + /// + private readonly struct DatabaseOperand(int database) : IRespArgument + { + public void WriteTo(scoped ref RespCommandHandler handler) + { + if (database < 0) return; // absent: no tokens, no count + handler.AppendFormatted(RespLiterals.Db); + handler.AppendFormatted((RedisValue)database); + } + } + + /// + /// As the hash group's selector, over the un-prefixed commands. The same rejection applies: + /// KEEPTTL and PERSIST are not deadlines, and PERSIST is its own command. + /// + private static RedisCommand SelectKeyExpireCommand(Expiration expiry) + { + if (expiry.IsKeepTtl || expiry.IsPersist || !(expiry.IsAbsolute || expiry.IsRelative)) + { + throw new ArgumentException( + "A deadline is required; KEEPTTL and PERSIST are not expirations, and PERSIST is a separate command.", + nameof(expiry)); + } + + return expiry.IsAbsolute + ? (expiry.IsMilliseconds ? RedisCommand.PEXPIREAT : RedisCommand.EXPIREAT) + : (expiry.IsMilliseconds ? RedisCommand.PEXPIRE : RedisCommand.EXPIRE); + } + } +} diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index 4567aa4aa..fb4840c98 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -171,6 +171,10 @@ private sealed class DefaultHandlers : IRespHandler>, IRespHandler?>, IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, + IRespHandler, IRespHandler, IRespHandler>, IRespHandler, @@ -357,6 +361,52 @@ ReadOnlyLease IRespHandler>.Parse( return pooled is null ? ReadOnlyLease.Empty : ReadOnlyLease.Adopt(pooled, count); } + RedisKey IRespHandler.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + return reader.IsNull ? default : (RedisKey)reader.ReadString()!; + } + + /// + /// Parsed through the generated token table rather than Enum.TryParse, because the wire + /// spellings are not the member names: a sorted set is zset. + /// + RedisType IRespHandler.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + RedisType result; + unsafe + { + if (!reader.TryParseScalar(&RedisTypeMetadata.TryParse, out result)) result = RedisType.Unknown; + } + + return result; + } + + /// + /// PTTL answers -2 for "no such key" and -1 for "no expiry", and the old + /// surface collapses both to null - the caller asked how long is left, and in both cases the + /// answer is "no deadline". Distinguishing them is what EXISTS is for. + /// + TimeSpan? IRespHandler.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + var ms = reader.ReadInt64(); + return ms < 0 ? null : TimeSpan.FromMilliseconds(ms); + } + + /// As the handler: negative means there is no deadline to report. + DateTime? IRespHandler.Parse(ReadOnlySpan response) + { + var reader = new RespReader(response); + reader.MoveNext(); + var ms = reader.ReadInt64(); + return ms < 0 ? null : DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime; + } + RedisValue[] IRespHandler.Parse(ReadOnlySpan response) { var reader = new RespReader(response); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index c65010496..0cef8ceef 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -192,6 +192,10 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]StackExchange.Redis.Interpolated.RespHashes.Context.get -> StackExchange.Redis.Interpolated.RespContext [SER010]StackExchange.Redis.Interpolated.RespHashes.RespHashes() -> void [SER010]StackExchange.Redis.Interpolated.RespHashes.RespHashes(in StackExchange.Redis.Interpolated.RespContext context) -> void +[SER010]StackExchange.Redis.Interpolated.RespKeys +[SER010]StackExchange.Redis.Interpolated.RespKeys.Context.get -> StackExchange.Redis.Interpolated.RespContext +[SER010]StackExchange.Redis.Interpolated.RespKeys.RespKeys() -> void +[SER010]StackExchange.Redis.Interpolated.RespKeys.RespKeys(in StackExchange.Redis.Interpolated.RespContext context) -> void [SER010]StackExchange.Redis.Interpolated.RespPayload [SER010]StackExchange.Redis.Interpolated.RespPayload.Dispose() -> void [SER010]StackExchange.Redis.Interpolated.RespPayload.GetReader() -> RESPite.Messages.RespReader @@ -229,12 +233,14 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Keys.get -> StackExchange.Redis.Interpolated.RespKeys [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Sets.get -> StackExchange.Redis.Interpolated.RespSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).SortedSets.get -> StackExchange.Redis.Interpolated.RespSortedSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(StackExchange.Redis.Interpolated.IRespTarget!).Strings.get -> StackExchange.Redis.Interpolated.RespStrings [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext) [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Bitmaps.get -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Hashes.get -> StackExchange.Redis.Interpolated.RespHashes +[SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Keys.get -> StackExchange.Redis.Interpolated.RespKeys [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Sets.get -> StackExchange.Redis.Interpolated.RespSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).SortedSets.get -> StackExchange.Redis.Interpolated.RespSortedSets [SER010]StackExchange.Redis.Interpolated.RespSurface.extension(in StackExchange.Redis.Interpolated.RespContext).Strings.get -> StackExchange.Redis.Interpolated.RespStrings @@ -282,14 +288,22 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.CombineWithScores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.SetOperation operation, System.ReadOnlySpan keys, System.ReadOnlySpan weights = default(System.ReadOnlySpan), StackExchange.Redis.Aggregate aggregate = StackExchange.Redis.Aggregate.Sum, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Contains(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Copy(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey source, StackExchange.Redis.RedisKey destination, int destinationDatabase = -1, bool replace = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Count(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespKeys keys, System.ReadOnlySpan targets, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Delete(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.ValueCondition when = default(StackExchange.Redis.ValueCondition), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Digest(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Dump(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask?> [SER010]static StackExchange.Redis.Interpolated.RespSurface.ExecuteAsync(this StackExchange.Redis.Interpolated.IRespTarget! target, string! command, System.ReadOnlyMemory args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Exists(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Exists(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Exists(this in StackExchange.Redis.Interpolated.RespKeys keys, System.ReadOnlySpan targets, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Expire(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.Expiration expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Expire(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.Expiration expiry, StackExchange.Redis.ExpireWhen when = StackExchange.Redis.ExpireWhen.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.ExpireTime(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Field(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, System.ReadOnlySpan operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Get(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -328,15 +342,18 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceLength(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.LongestCommonSubsequenceWithMatches(this in StackExchange.Redis.Interpolated.RespStrings strings, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, long minLength = 0, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Members(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Move(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, int database, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Move(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey source, StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisValue value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Operation(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.Bitwise operation, StackExchange.Redis.RedisKey destination, System.ReadOnlySpan keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan fields, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Persist(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSets sets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Pop(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, System.ReadOnlySpan keys, long count, StackExchange.Redis.Order order = StackExchange.Redis.Order.Ascending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Position(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, bool bit, long start = 0, long end = -1, StackExchange.Redis.StringIndexType indexType = StackExchange.Redis.StringIndexType.Byte, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Random(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomField(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFields(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.RandomFieldsWithValues(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> @@ -359,6 +376,7 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByRank(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, long start, long stop, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByScore(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, double start, double stop, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.RemoveRangeByValue(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue min, StackExchange.Redis.RedisValue max, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Rename(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisKey newKey, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Score(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Scores(this in StackExchange.Redis.Interpolated.RespSortedSets sortedSets, StackExchange.Redis.RedisKey key, System.ReadOnlySpan members, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.Set(this in StackExchange.Redis.Interpolated.RespBitmaps bitmaps, StackExchange.Redis.RedisKey key, long offset, bool bit, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask @@ -371,11 +389,19 @@ StackExchange.Redis.RespReaderLeaseExtensions [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.SetWithExpiry(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, System.ReadOnlySpan entries, StackExchange.Redis.Expiration expiry = default(StackExchange.Redis.Expiration), StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.StringLength(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.TimeToLive(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Touch(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Touch(this in StackExchange.Redis.Interpolated.RespKeys keys, System.ReadOnlySpan targets, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Type(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Unlink(this in StackExchange.Redis.Interpolated.RespKeys keys, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask +[SER010]static StackExchange.Redis.Interpolated.RespSurface.Unlink(this in StackExchange.Redis.Interpolated.RespKeys keys, System.ReadOnlySpan targets, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask [SER010]static StackExchange.Redis.Interpolated.RespSurface.Values(this in StackExchange.Redis.Interpolated.RespHashes hashes, StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.ValueTask!> [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Bitmaps(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespBitmaps [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespHashes [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Hashes(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespHashes +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Keys(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespKeys +[SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Keys(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespKeys [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespSets [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_Sets(in StackExchange.Redis.Interpolated.RespContext context) -> StackExchange.Redis.Interpolated.RespSets [SER010]static StackExchange.Redis.Interpolated.RespSurface.get_SortedSets(StackExchange.Redis.Interpolated.IRespTarget! target) -> StackExchange.Redis.Interpolated.RespSortedSets diff --git a/tests/StackExchange.Redis.Tests/DatabaseTests.cs b/tests/StackExchange.Redis.Tests/DatabaseTests.cs index c4e8ba168..7338e8188 100644 --- a/tests/StackExchange.Redis.Tests/DatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/DatabaseTests.cs @@ -76,8 +76,13 @@ public async Task CountKeys() Skip.IfMissingDatabase(conn, db1Id); Skip.IfMissingDatabase(conn, db2Id); var server = GetAnyPrimary(conn); - server.FlushDatabase(db1Id, CommandFlags.FireAndForget); - server.FlushDatabase(db2Id, CommandFlags.FireAndForget); + + // NOT fire-and-forget: everything below depends on these having happened, and this connection + // is disposed on the next line, so nothing would wait for them. The databases are dedicated but + // not fresh - GetDedicatedDB restarts its counter every process, so the same index belongs to + // whichever test drew it that run, and leftovers from a previous run land in the counts below. + await server.FlushDatabaseAsync(db1Id); + await server.FlushDatabaseAsync(db2Id); } await using (var conn = Create(defaultDatabase: db2Id)) { diff --git a/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs b/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs new file mode 100644 index 000000000..66c8e7183 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespSurfaceKeysTests.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interpolated; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// The key-command group: what goes on the wire, and which replies may be cached. +/// +/// +/// The old surface spells these with a Key prefix because one interface had to hold everything; +/// here the receiver is the group. These tests pin the rendered bytes rather than the method names, which +/// is the part a server cares about. +/// +public class RespSurfaceKeysTests +{ + private sealed class FakeExecutor(params string[] replies) : IRespExecutor + { + private int _next; + + public List Sent { get; } = []; + + public int Database => 0; + + public RespPayload Send(in RespRequest request) + { + Sent.Add(Encoding.UTF8.GetString(request.Span.ToArray()).Replace("\r\n", "|")); + return RespPayload.Create(Encoding.UTF8.GetBytes(replies[Math.Min(_next++, replies.Length - 1)])); + } + + public ValueTask SendAsync(RespRequest request, CancellationToken cancellationToken = default) + => new(Send(request)); + } + + private static (RespContext Context, FakeExecutor Executor) Target(params string[] replies) + { + var executor = new FakeExecutor(replies.Length == 0 ? [":1\r\n"] : replies); + return (new RespContext().WithExecutor(executor), executor); + } + + [Fact] + public async Task SingleAndVariadicAreTheSameCommand() + { + var (ctx, exec) = Target(); + + await ctx.Keys.Delete("k"); + await ctx.Keys.Delete([(RedisKey)"a", (RedisKey)"b"]); + await ctx.Keys.Exists("k"); + await ctx.Keys.Exists([(RedisKey)"a", (RedisKey)"b"]); + + Assert.Equal( + new[] + { + "*2|$3|DEL|$1|k|", + "*3|$3|DEL|$1|a|$1|b|", + "*2|$6|EXISTS|$1|k|", + "*3|$6|EXISTS|$1|a|$1|b|", + }, + exec.Sent); + } + + /// An empty run is not a command: an arity-zero DEL is a server error, and the answer is zero. + [Fact] + public async Task NoKeysMeansNoCommand() + { + var (ctx, exec) = Target(); + + Assert.Equal(0, await ctx.Keys.Delete(ReadOnlySpan.Empty)); + Assert.Equal(0, await ctx.Keys.Exists(ReadOnlySpan.Empty)); + Assert.Equal(0, await ctx.Keys.Touch(ReadOnlySpan.Empty)); + Assert.Empty(exec.Sent); + } + + /// + /// The expiry decides the command, not the caller. + /// + /// + /// Whether a deadline is absolute or relative, and whether it is seconds or milliseconds, are + /// properties of the - so one method covers four commands and the caller never + /// spells a command name. + /// + [Theory] + [InlineData("relative-seconds", "*3|$6|EXPIRE|$1|k|$2|60|")] + [InlineData("relative-millis", "*3|$7|PEXPIRE|$1|k|$5|60500|")] + [InlineData("absolute-seconds", "*3|$8|EXPIREAT|$1|k|$10|1700000000|")] + [InlineData("absolute-millis", "*3|$9|PEXPIREAT|$1|k|$13|1700000000500|")] + public async Task TheExpirationPicksTheCommand(string which, string expected) + { + var (ctx, exec) = Target(); + Expiration expiry = which switch + { + "relative-seconds" => TimeSpan.FromSeconds(60), + "relative-millis" => TimeSpan.FromMilliseconds(60_500), // not a whole second: must stay in millis + // a whole second renders as EXPIREAT; only a fraction forces the millisecond form + "absolute-seconds" => DateTimeOffset.FromUnixTimeMilliseconds(1700000000000).UtcDateTime, + _ => DateTimeOffset.FromUnixTimeMilliseconds(1700000000500).UtcDateTime, + }; + + await ctx.Keys.Expire("k", expiry); + Assert.Equal(expected, Assert.Single(exec.Sent)); + } + + /// PERSIST is a command, not an expiry, and saying otherwise is an error rather than a reroute. + [Fact] + public async Task PersistIsNotAnExpiration() + { + var (ctx, _) = Target(); + await Assert.ThrowsAsync(async () => await ctx.Keys.Expire("k", Expiration.Persist)); + } + + /// The optional COPY operands are holes: one command shape covers all four combinations. + [Theory] + [InlineData(-1, false, "*3|$4|COPY|$1|a|$1|b|")] + [InlineData(-1, true, "*4|$4|COPY|$1|a|$1|b|$7|REPLACE|")] + [InlineData(3, false, "*5|$4|COPY|$1|a|$1|b|$2|DB|$1|3|")] + [InlineData(3, true, "*6|$4|COPY|$1|a|$1|b|$2|DB|$1|3|$7|REPLACE|")] + public async Task CopyOperandsAreHolesNotBranches(int db, bool replace, string expected) + { + var (ctx, exec) = Target(); + await ctx.Keys.Copy("a", "b", db, replace); + Assert.Equal(expected, Assert.Single(exec.Sent)); + } + + /// TYPE reads through the token table, so the wire spellings survive. + /// zset is the case that catches a naive Enum.TryParse. + [Theory] + [InlineData("+zset\r\n", RedisType.SortedSet)] + [InlineData("+string\r\n", RedisType.String)] + [InlineData("+none\r\n", RedisType.None)] + public async Task TypeIsReadThroughTheTokenTable(string reply, RedisType expected) + { + var (ctx, _) = Target(reply); + Assert.Equal(expected, await ctx.Keys.Type("k")); + } + + /// "No such key" and "no expiry" both read as null; EXISTS is what tells them apart. + [Theory] + [InlineData(":-2\r\n")] + [InlineData(":-1\r\n")] + public async Task AbsentDeadlinesReadAsNull(string reply) + { + var (ctx, _) = Target(reply); + Assert.Null(await ctx.Keys.TimeToLive("k")); + + var (ctx2, _) = Target(reply); + Assert.Null(await ctx2.Keys.ExpireTime("k")); + } + + [Fact] + public async Task DeadlinesComeBackAsTimeAndInstant() + { + var (ctx, _) = Target(":60000\r\n"); + Assert.Equal(TimeSpan.FromSeconds(60), await ctx.Keys.TimeToLive("k")); + + var (ctx2, _) = Target(":1700000000000\r\n"); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1700000000000).UtcDateTime, + await ctx2.Keys.ExpireTime("k")); + } + + /// + /// The three commands here that must never be cached, and one next to them that must. + /// + /// + /// TOUCH exists for its side effect, so answering it locally skips the only thing it was called + /// for. RANDOMKEY is meant to differ each time and names no key, so nothing could invalidate it. + /// PTTL counts down. PEXPIRETIME names an instant and does not drift, which is why it is + /// the control rather than a fourth exclusion. + /// + [Fact] + public async Task TheUncacheableKeyCommandsSaySo() + { + static async Task<(bool Cached, long Refused)> Run(string reply, Func go) + { + using var cache = new RespClientCache(); + var executor = new FakeExecutor(reply); + var ctx = new RespContext().WithExecutor(executor).WithCache(cache); + await go(ctx); + await go(ctx); + return (executor.Sent.Count == 1, cache.RefusedByFlags); + } + + var (touch, touchRefused) = await Run(":1\r\n", static async c => await c.Keys.Touch("k")); + Assert.False(touch, "TOUCH was served from cache"); + Assert.True(touchRefused > 0); + + var (random, _) = await Run("$1\r\na\r\n", static async c => await c.Keys.Random()); + Assert.False(random, "RANDOMKEY was served from cache"); + + var (ttl, _) = await Run(":60000\r\n", static async c => await c.Keys.TimeToLive("k")); + Assert.False(ttl, "PTTL was served from cache"); + + // ...and the control: an instant does not drift, so this one is cacheable + var (when, _) = await Run(":1700000000000\r\n", static async c => await c.Keys.ExpireTime("k")); + Assert.True(when, "PEXPIRETIME should be cacheable"); + } +} From cc2781ac279f19ad2087eb9a6befae165c60d24a Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 06:16:50 +0100 Subject: [PATCH 149/360] Record the framing: this is V4, not a parallel option The spike is the next major version's core, and the two implementations never run in parallel. Four consequences worth stating because they change how the rest of the queue reads. The old IDatabase surface survives - binary compatibility is paramount - but the old implementation does not, which makes the internal ...Array siblings permanent rather than transitional. Fallback() must reach zero, so MULTI/WATCH/HIMPORT/EVALSHA stop being acceptable residue and become release blockers; deferring them is a temporary state with a mandatory exit. That makes the raw executor mandatory rather than optional, and sizing its real cost - MOVED/ASK, backlog, timeouts, retry, profiling, high-integrity - a release-planning question rather than an architectural preference. And [Experimental] becomes a staging label rather than a hedge: public shapes are effectively permanent from here, and SER352's count turns from a progress bar into a release gate. --- design/interpolated-resp-writer.queue.md | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 439c395de..9cc68b51a 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -9,6 +9,37 @@ a line saying why, because "we decided not to" is worth as much as "we did". --- +## Framing: this is V4, and the two implementations never run in parallel + +Decided 2026-09-15, and it changes how several items below should be read. The spike is not a parallel +alternative that might ship - it is the next major version's core, and the old implementation goes. + +Four consequences, none of them cosmetic: + +1. **The old `IDatabase` surface still exists; the old *implementation* does not.** Binary compatibility is + paramount here, so the signatures stay and are served by the new core. That makes the internal + `...Array` siblings **permanent**, not transitional - their doc comments currently say "goes when the + old one does", which is now only true if the old *API* ever goes, which it may not. + +2. **`Fallback()` must reach zero.** `TransitionalDatabase` currently delegates transactions to + `RedisDatabase`. With nothing to delegate to, `MULTI`/`WATCH`/`HIMPORT`/`EVALSHA` stop being acceptable + permanent residue and become **release blockers**. Deferring them is a temporary state with a mandatory + exit, not an end state. + +3. **Therefore the raw executor is mandatory, not optional.** The `Message` shim cannot be the permanent + execution path, because the commands it cannot express are commands V4 has to ship. Its full cost - + MOVED/ASK redirection, backlog, timeouts, retry, profiling, high-integrity checksums - has to be paid by + something. Sizing that is now a **release-planning** question, and finding out late is how a version + fails to ship. + +4. **`[Experimental]` is a staging label, not a hedge.** "We can change it later because it is + experimental" stops being true. Public shapes - lease returns, group names, `Keys` over `Keyspace` - + are effectively permanent from here, which raises the value of settling them now and lowers the value of + leaving options open. SER352's 354 members stop being a progress bar and become a release gate, which + is what the Release-build warning was asked for in the first place. + +--- + ## Now - [ ] **Two cacheability calls wanting a second opinion.** `DUMP` (a serialised payload - stable for a From 662bba0e3fab6305166ca7ecd149b12a26f9ba20 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 06:18:23 +0100 Subject: [PATCH 150/360] Correct the V4 note: composition, not a second write path Yesterday's note said the raw executor was mandatory. That was wrong, and the reason it was wrong is the reason parallel implementations are rejected at all: the backlog is one ConcurrentQueue per bridge, so two owners of a socket's write path cannot share ordering, replay, retry accounting or the reconnect handshake. A raw executor beside the shim is not unattractive, it is incoherent. And since FrameMessage is a Message, frames already have all of those properties. The shim is not a hack to escape from; it is what grants them. So the vexing commands need composition rather than a new path, and both mechanisms already exist one layer down: IMultiMessage, which is how TransactionMessage writes MULTI/EXEC as a unit, and write-lock injection, which is how HashImport places PREPARE once the connection is known. The frame surface just has no frame-shaped participant in either. That removes the release-blocking risk recorded yesterday: nothing about MOVED/ ASK, backlog, timeouts, retry, profiling or high-integrity needs reimplementing, because the path that has them is the path we stay on. --- design/interpolated-resp-writer.queue.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 9cc68b51a..4d6ee86d7 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -26,11 +26,20 @@ Four consequences, none of them cosmetic: permanent residue and become **release blockers**. Deferring them is a temporary state with a mandatory exit, not an end state. -3. **Therefore the raw executor is mandatory, not optional.** The `Message` shim cannot be the permanent - execution path, because the commands it cannot express are commands V4 has to ship. Its full cost - - MOVED/ASK redirection, backlog, timeouts, retry, profiling, high-integrity checksums - has to be paid by - something. Sizing that is now a **release-planning** question, and finding out late is how a version - fails to ship. +3. **The `Message` layer is load-bearing, and a second write path is not merely unattractive - it is + incoherent.** The backlog is one `ConcurrentQueue` per bridge, and `FrameMessage : Message`, so + frames *already* participate in ordering, backlog replay, retry accounting and the reconnect handshake. + Two owners of one socket's write path cannot share any of those. So "build a raw executor beside the + shim" is not an option at all, and the shim is not a hack to escape - it is what lets frames have those + properties for free. + + **Which means the vexing commands need composition, not a new path.** Both mechanisms already exist in + that layer: `IMultiMessage` (one logical message expanding into several, written as a unit - this is how + `TransactionMessage` does MULTI/EXEC today) and write-lock injection (a connection-local preamble decided + once the connection is known - this is how `HashImport` does PREPARE). What the frame surface lacks is a + frame-shaped participant in each. That is a far smaller and strictly additive piece of work than a raw + executor, and none of MOVED/ASK, backlog, timeouts, retry, profiling or high-integrity has to be + reimplemented, because we never leave the path that already has them. 4. **`[Experimental]` is a staging label, not a hedge.** "We can change it later because it is experimental" stops being true. Public shapes - lease returns, group names, `Keys` over `Keyspace` - From bf221f88c7f9945573dd66f9b92579f2113bd633 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 06:26:41 +0100 Subject: [PATCH 151/360] The array siblings are permanent fixtures, and say so IDatabase is not going anywhere - compatibility outranks tidiness - so the 29 internal ...Array siblings are how those signatures are served from the new core, for as long as the signatures exist. Their comments said "goes when the old one does", which would have invited someone to delete them later; they now say what they are, and why they are internal: the array is the old spelling, and new code should not be able to pick the shape it has no way to reclaim. RespHandlers.Values gets the explanation it was supposed to get when it was demoted - the earlier edit was behind a conditional that silently did not match, so it landed with only its one-line summary. Also records why Message stays: it is abstract over exactly two members, ArgCount and WriteImpl, with everything else concrete shared bookkeeping. The core is already pluggable at the one step the frame path wanted to replace, so a new IMessage would re-spell a seam that is already two members wide. What V4 changes is the population, not the abstraction - 36 subclasses exist mainly to render one command each, and each migration deletes one. --- design/interpolated-resp-writer.queue.md | 16 +++- .../Interpolated/RespSurface.Hashes.cs | 96 ++++++++++++++++--- .../Interpolated/RespSurface.Sets.cs | 40 +++++++- .../Interpolated/RespSurface.SortedSets.cs | 88 ++++++++++++++--- .../Interpolated/RespSurface.Strings.cs | 8 +- .../Interpolated/RespSurface.cs | 7 ++ 6 files changed, 221 insertions(+), 34 deletions(-) diff --git a/design/interpolated-resp-writer.queue.md b/design/interpolated-resp-writer.queue.md index 4d6ee86d7..d7c7145c7 100644 --- a/design/interpolated-resp-writer.queue.md +++ b/design/interpolated-resp-writer.queue.md @@ -17,9 +17,19 @@ alternative that might ship - it is the next major version's core, and the old i Four consequences, none of them cosmetic: 1. **The old `IDatabase` surface still exists; the old *implementation* does not.** Binary compatibility is - paramount here, so the signatures stay and are served by the new core. That makes the internal - `...Array` siblings **permanent**, not transitional - their doc comments currently say "goes when the - old one does", which is now only true if the old *API* ever goes, which it may not. + paramount here, so the signatures stay and are served by the new core, and people are *led* to the new + surface rather than pushed. That makes the internal `...Array` siblings **permanent fixtures**, not + scaffolding, and their doc comments now say so. + + **`Message` stays too**, and for a better reason than inertia: it is abstract over exactly two members, + `ArgCount` and `WriteImpl`. Everything else it carries - db, flags, command, slot, status, timeouts, + result pairing, high-integrity, profiling - is concrete shared bookkeeping. So the core is already + pluggable at precisely the rendering step, which is the only step the frame path wanted to replace, and + a new `IMessage` would only re-spell a seam that is already two members wide. What V4 changes is not the + abstraction but the population: 36 `Message` subclasses exist mainly to implement `WriteImpl` for one + command each, and every command that moves to the writer makes one of them redundant. The end state is a + **deletion**, not a reconciliation. The innards may evolve once sync is no longer a requirement; that is + deferred, and it is a smaller cut than it first looked. 2. **`Fallback()` must reach zero.** `TransitionalDatabase` currently delegates transactions to `RedisDatabase`. With nothing to delegate to, `MULTI`/`WATCH`/`HIMPORT`/`EVALSHA` stop being acceptable diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs index 28cea77c8..b876f1a5f 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Hashes.cs @@ -79,7 +79,13 @@ public static ValueTask> Get(this in RespHashes hashes /// /// Internal sibling of Get. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty @@ -109,7 +115,13 @@ public static ValueTask> GetAll(this in RespHashes hash /// /// Internal sibling of GetAll. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetAllArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( @@ -127,7 +139,13 @@ public static ValueTask> Keys(this in RespHashes hashe /// /// Internal sibling of Keys. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask KeysArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( @@ -145,7 +163,13 @@ public static ValueTask> Values(this in RespHashes has /// /// Internal sibling of Values. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask ValuesArray(this in RespHashes hashes, RedisKey key, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( @@ -198,7 +222,13 @@ public static ValueTask> RandomFields(this in RespHash /// /// Internal sibling of RandomFields. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RandomFieldsArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( @@ -218,7 +248,13 @@ public static ValueTask> RandomFieldsWithValues(this in /// /// Internal sibling of RandomFieldsWithValues. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RandomFieldsWithValuesArray(this in RespHashes hashes, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => hashes.Context.SendAsync( @@ -372,7 +408,13 @@ public static ValueTask> Expire( /// /// Internal sibling of Expire. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask ExpireArray( this in RespHashes hashes, @@ -406,7 +448,13 @@ public static ValueTask> Persist(this in RespHashes /// /// Internal sibling of Persist. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask PersistArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty @@ -435,7 +483,13 @@ public static ValueTask> GetTimeToLive(this in RespHashes ha /// /// Internal sibling of GetTimeToLive. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetTimeToLiveArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty @@ -475,7 +529,13 @@ public static ValueTask> GetExpireDateTime(this in RespHashe /// /// Internal sibling of GetExpireDateTime. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetExpireDateTimeArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty @@ -513,7 +573,13 @@ public static ValueTask> GetDelete(this in RespHashes /// /// Internal sibling of GetDelete. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetDeleteArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, CommandFlags flags = CommandFlags.None) => fields.IsEmpty @@ -567,7 +633,13 @@ public static ValueTask> GetSetExpiry(this in RespHash /// /// Internal sibling of GetSetExpiry. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask GetSetExpiryArray(this in RespHashes hashes, RedisKey key, ReadOnlySpan fields, Expiration expiry = default, CommandFlags flags = CommandFlags.None) => fields.IsEmpty diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs index e4f4c6bda..e14fb34da 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Sets.cs @@ -105,7 +105,13 @@ public static ValueTask> Contains(this in RespSets sets, Red /// /// Internal sibling of Contains. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask ContainsArray(this in RespSets sets, RedisKey key, ReadOnlySpan values, CommandFlags flags = CommandFlags.None) => values.IsEmpty @@ -133,7 +139,13 @@ public static ValueTask> Members(this in RespSets sets /// /// Internal sibling of Members. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask MembersArray(this in RespSets sets, RedisKey key, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( @@ -177,7 +189,13 @@ public static ValueTask> Pop(this in RespSets sets, Re /// /// Internal sibling of Pop. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask PopArray(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => count == 0 @@ -206,7 +224,13 @@ public static ValueTask> RandomMembers(this in RespSet /// /// Internal sibling of RandomMembers. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RandomMembersArray(this in RespSets sets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sets.Context.SendAsync( @@ -234,7 +258,13 @@ public static ValueTask> Combine(this in RespSets sets /// /// Internal sibling of Combine. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask CombineArray(this in RespSets sets, SetOperation operation, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) { diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs index 65dd31ce4..a395e9e57 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.SortedSets.cs @@ -189,7 +189,13 @@ public static ValueTask Remove(this in RespSortedSets sortedSets, RedisKey /// /// Internal sibling of Scores. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask ScoresArray(this in RespSortedSets sortedSets, RedisKey key, ReadOnlySpan members, CommandFlags flags = CommandFlags.None) => members.IsEmpty @@ -283,7 +289,13 @@ public static ValueTask> RandomMembers(this in RespSor /// /// Internal sibling of RandomMembers. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RandomMembersArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( @@ -303,7 +315,13 @@ public static ValueTask> RandomMembersWithScores(t /// /// Internal sibling of RandomMembersWithScores. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RandomMembersWithScoresArray(this in RespSortedSets sortedSets, RedisKey key, long count, CommandFlags flags = CommandFlags.None) => sortedSets.Context.SendAsync( @@ -336,7 +354,13 @@ public static ValueTask> RangeByRank( /// /// Internal sibling of RangeByRank. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RangeByRankArray( this in RespSortedSets sortedSets, @@ -375,7 +399,13 @@ public static ValueTask> RangeByRankWithScores( /// /// Internal sibling of RangeByRankWithScores. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RangeByRankWithScoresArray( this in RespSortedSets sortedSets, @@ -422,7 +452,13 @@ public static ValueTask> RangeByScore( /// /// Internal sibling of RangeByScore. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RangeByScoreArray( this in RespSortedSets sortedSets, @@ -462,7 +498,13 @@ public static ValueTask> RangeByScoreWithScores( /// /// Internal sibling of RangeByScoreWithScores. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RangeByScoreWithScoresArray( this in RespSortedSets sortedSets, @@ -517,7 +559,13 @@ public static ValueTask> RangeByValue( /// /// Internal sibling of RangeByValue. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask RangeByValueArray( this in RespSortedSets sortedSets, @@ -669,7 +717,13 @@ public static ValueTask> Combine( /// /// Internal sibling of Combine. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask CombineArray( this in RespSortedSets sortedSets, @@ -706,7 +760,13 @@ public static ValueTask> CombineWithScores( /// /// Internal sibling of CombineWithScores. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask CombineWithScoresArray( this in RespSortedSets sortedSets, @@ -788,7 +848,13 @@ public static ValueTask> Pop(this in RespSortedSet /// /// Internal sibling of Pop. A sibling rather than a conversion: IDatabase promises /// an array the caller owns, so going via the lease would rent a pooled buffer only to copy out of - /// it. Internal, so it never reaches the public surface and goes when the old one does. + /// it. + /// + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how that signature is served from the new core, for as long + /// as the signature exists. Internal because the array is the old spelling: new code should + /// reach for the lease, and nothing outside this assembly should be able to choose otherwise. + /// /// internal static ValueTask PopArray(this in RespSortedSets sortedSets, RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) { diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs index 0ff16a9ee..514060802 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.Strings.cs @@ -140,9 +140,11 @@ public static ValueTask> Get(this in RespStrings strin /// line and no knowledge; the command is still written once anywhere it matters. /// /// - /// Internal because it must never reach the public surface: it exists to serve a shape that is on - /// its way out, and when the old surface goes, so does this - with no binary consequence, because - /// nothing outside this assembly could ever have bound to it. + /// Permanent, not scaffolding. IDatabase is not going anywhere - compatibility + /// outranks tidiness here - so this is how StringGet(RedisKey[]) is served from the new + /// core, for as long as that signature exists. Internal because the array is the old + /// spelling: new code should reach for the lease, and nothing outside this assembly should be able + /// to choose otherwise. /// /// internal static ValueTask GetArray(this in RespStrings strings, ReadOnlySpan keys, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/Interpolated/RespSurface.cs b/src/StackExchange.Redis/Interpolated/RespSurface.cs index fb4840c98..c61021087 100644 --- a/src/StackExchange.Redis/Interpolated/RespSurface.cs +++ b/src/StackExchange.Redis/Interpolated/RespSurface.cs @@ -56,6 +56,13 @@ public static class RespHandlers public static IRespHandler Double { get; } = DefaultHandlers.Instance; /// Reads an array reply as s; a nil array reads as empty. + /// + /// Internal: the array is the old spelling, and it stays. The public surface returns + /// , so a caller can give the storage back; this serves the internal + /// ...Array siblings behind the IDatabase signatures, which are not going anywhere - + /// compatibility outranks tidiness. Internal rather than retired, so that new code cannot pick the + /// shape it has no way to reclaim. + /// internal static IRespHandler Values { get; } = DefaultHandlers.Instance; /// Reads an array reply into a pooled the caller gives back. From db6d4e3e50758ed696c92eec09ccd3c29c8f6919 Mon Sep 17 00:00:00 2001 From: mgravell Date: Tue, 15 Sep 2026 06:35:50 +0100 Subject: [PATCH 152/360] Plan the vexing commands as three probes, one per layer EVALSHA, MULTI and HIMPORT look like three awkward commands and are better understood as three different seams, which is why doing all three settles the question and doing one does not. EVALSHA is frame-level: a frame that can carry an alternate rendering, so NOSCRIPT is recovered by re-sending the same logical request as EVAL