|
| 1 | +--- |
| 2 | +name: implement-resp-command |
| 3 | +description: Add a new Redis/RESP command (or overload) to StackExchange.Redis end-to-end — enum, interfaces, RedisDatabase implementation, ResultProcessor, public-API tracking, and the ResultProcessor + RoundTrip unit tests. Use when asked to "add/implement/support a Redis command", wire up a new RESP command, expose a server feature on IDatabase/IDatabaseAsync, or add a result processor. |
| 4 | +--- |
| 5 | + |
| 6 | +# Implement a new RESP command |
| 7 | + |
| 8 | +This walks through adding a command to **StackExchange.Redis** (the `src/StackExchange.Redis` client). Read `AGENTS.md` first — especially **Public API tracking → Backwards compatibility is paramount** and **Architecture**. Do every step; the build and the API analyzer will fail loudly if you skip the wiring, but the *tests* are what prove the command actually works. |
| 9 | + |
| 10 | +Use an existing, similarly-shaped command as your template (e.g. `StringGet`/`GET` for a simple key command, `StreamAutoClaim`/`XAUTOCLAIM` for a structured aggregate reply). Grep `RedisDatabase.cs` for one and mirror it. |
| 11 | + |
| 12 | +## Steps |
| 13 | + |
| 14 | +1. **Add the command name to the `RedisCommand` enum** — `src/StackExchange.Redis/Enums/RedisCommand.cs`. The enum member name *is* the wire token (`CommandMap` serializes it via `command.ToString()`), so name it exactly as Redis expects (e.g. `GETEX`, `XAUTOCLAIM`). Keep the existing alphabetical grouping. |
| 15 | + - **Then classify it in `IsPrimaryOnly`** (the `switch` in the same file). That switch is **exhaustive** — its `default` *throws* `ArgumentOutOfRangeException` (*"Every RedisCommand must be defined in Message.IsPrimaryOnly…"*) at runtime for any unlisted command, so this is not optional. Put writes/mutations in the primary-only list; pure reads fall through to the replica-eligible branch. Getting it wrong mis-routes the command (e.g. a write sent to a replica). |
| 16 | + |
| 17 | +2. **Declare the method on the interfaces** — `src/StackExchange.Redis/Interfaces/IDatabase.cs` *and* `IDatabaseAsync.cs` (or the `.Arrays.cs` / `.VectorSets.cs` partials when relevant). Always provide both sync and async. |
| 18 | + - **Back-compat:** never add an optional parameter to an existing shipped method (binary break → `MissingMethodException`). Add a new **overload** instead; see `AGENTS.md`. |
| 19 | + - **Implement the new member on every `IDatabase`/`IDatabaseAsync` implementor**, or the build breaks. Chiefly `KeyspaceIsolation/KeyPrefixedDatabase.cs` — and there it must prefix keys via `ToInner(key)`; a stub that forwards without prefixing compiles but **silently breaks keyspace isolation** for the new command. If the command should also be usable in batches/transactions, add it to `IBatch`/`ITransaction` and their implementations (`RedisBatch`/`RedisTransaction`/`KeyPrefixedBatch`) too. |
| 20 | + |
| 21 | +3. **Implement in `RedisDatabase.cs`** (next to the template you picked). The standard shape: |
| 22 | + ```csharp |
| 23 | + public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) |
| 24 | + { |
| 25 | + var msg = Message.Create(Database, flags, RedisCommand.GET, key); |
| 26 | + return ExecuteSync(msg, ResultProcessor.RedisValue); |
| 27 | + } |
| 28 | + public Task<RedisValue> StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) |
| 29 | + { |
| 30 | + var msg = Message.Create(Database, flags, RedisCommand.GET, key); |
| 31 | + return ExecuteAsync(msg, ResultProcessor.RedisValue); |
| 32 | + } |
| 33 | + ``` |
| 34 | + For argument shapes `Message.Create` doesn't cover (optional tokens, variadic args, multiple round-trips), write a private `Message` subclass overriding `WriteImpl` (search `RedisDatabase.cs` for `: Message` and `GetStringGetExMessage` for examples), or an `IMultiMessage`. |
| 35 | + |
| 36 | +4. **Pick or write the `ResultProcessor<T>`** — `src/StackExchange.Redis/ResultProcessor.cs`. Reuse an existing one if the reply shape matches (`RedisValue`, `RedisValueArray`, `Int64`, `Boolean`, `Lease`, …). Otherwise add a nested `internal sealed class XProcessor : ResultProcessor<T>` overriding `SetResult(PhysicalConnection, Message, ref RespReader)` to parse the reply with the `RespReader`, and expose it as a `public static readonly` field. Handle RESP2 vs RESP3 and older-server reply variants here. |
| 37 | + |
| 38 | +5. **New result types** go in `src/StackExchange.Redis/APITypes/` (mirror `StreamAutoClaimResult` etc.). |
| 39 | + |
| 40 | +6. **Update public-API tracking** — add every new public member to `PublicAPI.Unshipped.txt` (and the `net6.0/` subfolder if the API only exists on newer TFMs). The build error tells you the exact line. See `AGENTS.md`. |
| 41 | + |
| 42 | +7. **Write the two unit-test layers** (below). These run with **no external server**, so they're the fast, reliable proof of correctness — write them even if you also add live integration tests. |
| 43 | + |
| 44 | +8. **Gate pre-release server features** behind `[Experimental(Experiments.Server_8_x)]` when appropriate (see `src/RESPite/Shared/Experiments.cs`). |
| 45 | + |
| 46 | +## Tests — the two layers that matter |
| 47 | + |
| 48 | +### ResultProcessor unit test (parsing in isolation) |
| 49 | + |
| 50 | +Proves your `ResultProcessor` turns raw RESP bytes into the right typed value. Add a class under `tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/` deriving `ResultProcessorUnitTest`; feed handcrafted RESP wire strings to `Execute(resp, ResultProcessor.X)` and assert on the result; use `ExecuteUnexpected(resp, ...)` for replies that must fail. Model it on `ResultProcessorUnitTests/StreamAutoClaim.cs`: |
| 51 | + |
| 52 | +```csharp |
| 53 | +public class MyCommand(ITestOutputHelper log) : ResultProcessorUnitTest(log) |
| 54 | +{ |
| 55 | + [Fact] |
| 56 | + public void Basic_Success() |
| 57 | + { |
| 58 | + var resp = "*2\r\n$3\r\n0-0\r\n*0\r\n"; // hand-built RESP reply |
| 59 | + var result = Execute(resp, ResultProcessor.MyCommand); |
| 60 | + Assert.Equal("0-0", result.NextStartId.ToString()); |
| 61 | + } |
| 62 | + |
| 63 | + [Fact] |
| 64 | + public void WrongShape_Failure() => ExecuteUnexpected("$5\r\nhello\r\n", ResultProcessor.MyCommand); |
| 65 | +} |
| 66 | +``` |
| 67 | +Cover the cases that actually bite: RESP2 **and** RESP3 forms, empty arrays, null (`$-1`/`*-1`), older-server reply shapes (e.g. a 2-element vs 3-element reply across versions), and at least one malformed reply via `ExecuteUnexpected`. |
| 68 | + |
| 69 | +### RoundTrip unit test (full write + read, still no server) |
| 70 | + |
| 71 | +Proves the command **serializes to the exact bytes** Redis expects *and* parses back correctly, exercising `Message.WriteTo` + the command-map. Add to `tests/StackExchange.Redis.Tests/RoundTripUnitTests/` using `TestConnection.ExecuteAsync(message, processor, requestResp, responseResp, ...)`, which asserts the outbound RESP equals `requestResp` and then feeds `responseResp` back through the processor. See `RoundTripUnitTests/AdhocMessageRoundTrip.cs`: |
| 72 | + |
| 73 | +```csharp |
| 74 | +[Theory(Timeout = 1000)] |
| 75 | +[InlineData("hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n")] |
| 76 | +public async Task MyCommand_RoundTrips(string payload, string requestResp) |
| 77 | +{ |
| 78 | + var msg = /* build the Message exactly as RedisDatabase does */; |
| 79 | + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.MyCommand, requestResp, ":5\r\n", log: log); |
| 80 | + Assert.Equal(5, result.AsInt32()); |
| 81 | +} |
| 82 | +``` |
| 83 | +Verify the precise outbound bytes (length prefixes included), and ideally that command-map **rename** and **disable** behave (the `MapMode` pattern in that file). |
| 84 | + |
| 85 | +### Optional: live integration test |
| 86 | + |
| 87 | +Only if you need to prove behavior against a real server — these need the docker Redis topology (see `AGENTS.md → Testing topology`). An **absent** server is skipped automatically by the test infrastructure, so you don't write code for that. |
| 88 | + |
| 89 | +What you *do* need to handle for a new command is **server version**: most new commands are new server features, and the test must skip as inconclusive on servers too old to support them. Use the `require:` parameter when creating the connection — it connects and auto-skips when the live server is below the threshold: |
| 90 | + |
| 91 | +```csharp |
| 92 | +await using var conn = Create(require: RedisFeatures.v7_4_0_rc1); |
| 93 | +var db = conn.GetDatabase(); |
| 94 | +// ... exercise the command ... |
| 95 | +``` |
| 96 | +Pick the `RedisFeatures.vX_Y_Z` constant matching the version that introduced the command (see `HashFieldTests.cs` / `CopyTests.cs` for the pattern). If your command needs a version threshold that doesn't exist yet, add the constant to `RedisFeatures`. This keeps the suite green across the range of server versions CI and contributors run against. |
| 97 | + |
| 98 | +The in-process managed server (`toys/StackExchange.Redis.Server`) may also need a handler if integration tests run against it. |
| 99 | + |
| 100 | +## Before finishing |
| 101 | + |
| 102 | +- `dotnet build Build.csproj -c Release /p:CI=true` — analyzers + `TreatWarningsAsErrors` must pass (this catches a missing `PublicAPI.Unshipped.txt` entry). |
| 103 | +- `dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -f net10.0 --filter "FullyQualifiedName~MyCommand"` — runs your new unit tests without any server. |
| 104 | +- Double-check no shipped signature changed (back-compat). |
0 commit comments