Skip to content

Commit 20b0453

Browse files
committed
propose agents guidelines and skills
1 parent cbbda97 commit 20b0453

6 files changed

Lines changed: 347 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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

Comments
 (0)