<< All versions

Skill v1.0.0

Trusted Publisher100/100
microsoft/garnet/add-garnet-command
──Details
PublishedSeptember 30, 2026 at 08:34 PM
Content Hashsha256:e08216fa73f840dd...
Git SHA
──Files
Files (1 file, 38.3 KB)
SKILL.md38.3 KBactive
SKILL.md · 840 lines · 38.3 KB

version: "1.0.0" name: add-garnet-command description: Adds a new built-in RESP command to Garnet end-to-end. Covers enum registration, parsing, dispatch, RESP handler, API surface, storage session, RMW callbacks, command metadata JSON, ACL tests, and integration tests. Use when asked to "add a command", "implement RI.SET", "add RESP command", or any new server command. Do NOT use for custom extension commands (CustomRawStringFunctions) or object-type sub-operations.


Add a New Built-in RESP Command to Garnet

Step-by-step guide for implementing a new built-in RESP command in Garnet. This covers every file that must be created or modified, the tools that must be run, caveats discovered during implementation, and how to verify correctness.

Scope: Built-in commands that are part of the Garnet server (not custom extensions registered via REGISTERCS).


Overview: What Must Change

Adding a single new command touches at minimum these areas:

#AreaFilesRequired?
1RespCommand enumlibs/server/Resp/Parser/RespCommand.cs✅ Always
2Command parsinglibs/server/Resp/Parser/RespCommand.cs✅ Always
3Command dispatchlibs/server/Resp/RespServerSession.cs✅ Always
4RESP handlerlibs/server/Resp/<Category>/*.cs✅ Always
5API interfacelibs/server/API/IGarnetApi.csIf key-value command (not blocking/admin)
6API delegationlibs/server/API/GarnetApi*.csIf key-value command (not blocking/admin)
7Storage session`libs/server/Storage/Session/[Main\Object\Unified]Store/*Ops.cs`If key-value command (not blocking/admin)
8RMW/Read callbacks`libs/server/Storage/Functions/[Main\Unified]Store/[RMW\Read]Methods.cs`If string/unified command using RMW/Read
8bRead responselibs/server/Storage/Functions/MainStore/PrivateMethods.csIf string command using Read (add to CopyRespToWithInput)
9VarLen methods`libs/server/Storage/Functions/[Main\Unified]Store/VarLenInputMethods.cs`If string/unified command using RMW
10Object operation enumlibs/server/Objects/[ObjectName]/[ObjectName]Object.csIf new object sub-operation
11Object implementationlibs/server/Objects/[ObjectName]/[ObjectName]ObjectImpl.csIf new object sub-operation
12ItemBrokerlibs/server/Objects/ItemBroker/CollectionItemBroker.csIf blocking command
13Command info JSONlibs/resources/RespCommandsInfo.json✅ Always (generated)
14Command docs JSONlibs/resources/RespCommandsDocs.json✅ Always (generated)
15Supported commandsplayground/CommandInfoUpdater/SupportedCommand.cs✅ Always
16Garnet command infoplayground/CommandInfoUpdater/GarnetCommandsInfo.jsonIf Garnet-only command
17Garnet command docsplayground/CommandInfoUpdater/GarnetCommandsDocs.jsonIf Garnet-only command
18ACL testtest/Garnet.test/Resp/ACL/RespCommandTests.cs✅ Always
19Integration teststest/Garnet.test/Resp*.cs✅ Always
20Website documentationwebsite/docs/commands/✅ Always
21Configuration settingsOptions.cs, GarnetServerOptions.cs, defaults.confIf command is optional/gated

Step 1: Add the RespCommand Enum Value

File: libs/server/Resp/Parser/RespCommand.cs

The RespCommand enum is writes-first: write (mutating) commands occupy a dense, explicitly-numbered block immediately after NONE, followed by reads, scripts, and non-data:

Write commands: APPEND = 1 ... BITOP_DIFF = 120 (EXPLICIT values; the ONLY persisted values)
Read commands: BITCOUNT ... RISCAN (auto-numbered; never persisted)
Script commands: EVAL, EVALSHA (LastDataCommand = EVALSHA)
Non-key commands: PING, SUBSCRIBE, etc.
Admin commands: AUTH, CONFIG, etc.

Why writes-first: the write command's numeric value is persisted to disk (serialized into RespInputHeader.cmd in the AOF and streamed to replicas). Keeping writes in a dense, explicitly-numbered leading block makes those values stable — adding a read/admin command (which is never persisted) cannot shift them.

Read/write classification uses enum ranges (positional constants, not hardcoded values):

  • RespCommandExtensions.FirstWriteCommand (APPEND=1) .. LastWriteCommand (BITOP_DIFF=120) → write
  • FirstReadCommand .. LastReadCommand (= EVAL - 1) → read-only

Rules for adding a command:

  • Write (mutating) command: APPEND it at the end of the write block, immediately before

the // Read-only commands marker, with the next explicit value (e.g. MYCMD = 121,). Then update LastWriteCommand if it is no longer BITOP_DIFF. Never insert into the middle of the write block or change/reuse an existing value — persisted values must be append-only.

  • Read-only / script / non-data command: add it in the appropriate (auto-numbered) section.

These are never persisted, so their values may shift freely.

  • Adding a write shifts the auto-numbered reads up by one — that is harmless (reads aren't

persisted) and requires no other change.

Guardrails (do not bypass):

  • PersistedEnumStabilityTests snapshots the write-block values and asserts the write range

contains exactly the write commands. If you change an existing write value it fails — that is a breaking on-disk change requiring an AofHeader.AofHeaderVersion bump + a legacy remap in AofProcessor / LegacyRespCommand, not an edited expected value.

  • If a new write command's value ever approaches the custom command range (0xFEFF), that is also

caught by a test.

Boundary markers to watch (search for these comments):

csharp
APPEND = 1, // Note: FirstWriteCommand — keep as the first write command
BITOP_DIFF = 120, // Note: LastWriteCommand — append new write commands after this with the next value
EVALSHA, // Note: Update LastDataCommand if adding new data commands after this

Step 2: Add Command Parsing

File: libs/server/Resp/Parser/RespCommand.cs

Two parsing tiers exist:

Hash table path: RespCommandHashLookup (primary path for most commands)

The hash table in libs/server/Resp/Parser/RespCommandHashLookupData.cs provides O(1) lookup for all built-in commands. This is the recommended path for all new commands.

To add a new primary command, add one line to PopulatePrimaryTable() in RespCommandHashLookupData.cs:

csharp
Add("DELIFGREATER", RespCommand.DELIFGREATER);

To add a command with subcommands (e.g., MYPARENT SUBCMD):

  1. Add the parent with hasSub: true in PopulatePrimaryTable():

``csharp Add("MYPARENT", RespCommand.MYPARENT, hasSub: true); ``

  1. Define the subcommand array in RespCommandHashLookupData.cs:

``csharp private static readonly (string Name, RespCommand Command)[] MyparentSubcommands = [ ("SUBCMD1", RespCommand.MYPARENT_SUBCMD1), ("SUBCMD2", RespCommand.MYPARENT_SUBCMD2), ]; ``

  1. Build the table in the static constructor in RespCommandHashLookup.cs:

``csharp myparentSubTable = BuildSubTable(MyparentSubcommands, out myparentSubTableMask); ``

  1. Wire it into LookupSubcommand() in RespCommandHashLookup.cs:

``csharp RespCommand.MYPARENT => (myparentSubTable, myparentSubTableMask), ``

⚠️ Important: Use the exact wire-protocol spelling for the hash table name string. Some commands use hyphens (e.g., "SET-CONFIG-EPOCH" not "SETCONFIGEPOCH"). Check CmdStrings.cs for the canonical spelling.

⚠️ Convention: Define the command name string in `libs/server/Resp/CmdStrings.cs` and reference it from the parser, rather than using inline "..."u8 literals. This keeps command name strings centralized and reusable (e.g., for error messages).

csharp
// In CmdStrings.cs:
public static ReadOnlySpan<byte> DELIFGREATER => "DELIFGREATER"u8;

SIMD fast path: FastParseCommand() (optional, for hottest commands only)

Static Vector128<byte> patterns defined in libs/server/Resp/Parser/RespCommandSimdPatterns.cs that match the full RESP encoding (*N\r\n$L\r\nCMD\r\n) in a single 16-byte comparison. Use the RespPattern(argCount, "CMD") helper to create new patterns. Only needed for the most performance-critical commands with:

  • Fixed argument count (single digit)
  • Command names of 3-6 characters (total encoded length must fit in 16 bytes)

Most new commands should NOT be added here — the hash table + MRU cache provide excellent performance for all commands. Only add SIMD patterns if benchmarking shows the command is a bottleneck.


Step 3: Add Command Dispatch

File: libs/server/Resp/RespServerSession.cs

Three dispatch methods exist, and which one you use matters for latency tracking:

MethodForLatency
ProcessBasicCommandsFast single/dual-arg commands@fast only
ProcessArrayCommandsFast multi-arg commands@fast only
ProcessOtherCommandsSlow commands, admin commands@slow OK

⚠️ WARNING: Do NOT add @slow-classified commands to ProcessBasicCommands or ProcessArrayCommands. This breaks latency tracking. If in doubt, use ProcessOtherCommands.

Pattern:

csharp
RespCommand.MYCMD => NetworkMYCMD(ref storageApi),

Add before the _ => ... fallthrough in the appropriate method.


Step 4: Implement the RESP Handler

New file or existing file in libs/server/Resp/

Command handlers are methods on the RespServerSession partial class:

csharp
private bool NetworkMYCMD<TGarnetApi>(ref TGarnetApi storageApi)
where TGarnetApi : IGarnetApi
{
// 1. Validate argument count
if (parseState.Count != N)
return AbortWithWrongNumberOfArguments(nameof(RespCommand.MYCMD));
// 2. Validate other inputs (short-circuit before going to storage)
var key = parseState.GetArgSliceByRef(0);
// e.g., parse and validate optional flags, numeric arguments, etc.
if (!parseState.TryGetInt(1, out var _))
{
WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER);
return true;
}
// 3. Build input/output and call storage API
// Note: To avoid double-parsing a parameter, you can pass a pre-parsed
// value in the input struct's auxiliary arguments (e.g., input.arg1).
var input = new StringInput(RespCommand.MYCMD, ref parseState, startIdx: 1);
var output = GetStringOutput();
var status = storageApi.MyOperation(key, ref input, ref output);
// 4. Write RESP response
if (status == GarnetStatus.OK)
{
ProcessOutput(output);
}
else
{
WriteError(CmdStrings.RESP_ERR_MY_MESSAGE);
}
return true;
}

Key patterns:

  • Arguments: parseState.GetArgSliceByRef(i) returns ref PinnedSpanByte
  • Input/Output: Instantiate StringInput/StringOutput (for string commands), ObjectInput/ObjectOutput (for object commands), or UnifiedInput/UnifiedOutput (for unified commands) before calling the storage API
  • Response (happy path): Use ProcessOutput(output) in the common case — this handles writing the RESP response from the output struct
  • Response (errors/special cases): Use RespServerSession extension methods (e.g., WriteError(...), WriteDirect(...), WriteInt64(...), etc.) — these handle SendAndReset() internally
  • Error strings: Store as u8 literals in CmdStrings (e.g., CmdStrings.RESP_ERR_MY_MESSAGE) rather than inline
  • Always return true — there are no partial executions

Object command RESP handler pattern

Object commands (Hash, List, Set, SortedSet) follow a similar pattern to string commands. The main difference is that the RESP handler uses ObjectInput/ObjectOutput with the appropriate operation enum and must handle WRONGTYPE errors:

File: libs/server/Resp/Objects/[ObjectName]Commands.cs (e.g., SortedSetCommands.cs)

csharp
private unsafe bool SortedSetAdd<TGarnetApi>(ref TGarnetApi storageApi)
where TGarnetApi : IGarnetApi
{
if (parseState.Count < 3)
return AbortWithWrongNumberOfArguments("ZADD");
var key = parseState.GetArgSliceByRef(0);
var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZADD };
var input = new ObjectInput(header, ref parseState, startIdx: 1);
var output = GetObjectOutput();
var status = storageApi.SortedSetAdd(key, ref input, ref output);
switch (status)
{
case GarnetStatus.WRONGTYPE:
while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend))
SendAndReset();
break;
default:
ProcessOutput(output.SpanByteAndMemory);
break;
}
return true;
}

Key differences from string commands:

  • Uses ObjectInput with a RespInputHeader(GarnetObjectType.XXX) and an operation enum (e.g., SortedSetOperation.ZADD)
  • Must handle GarnetStatus.WRONGTYPE — object commands can fail if the key holds a different object type
  • The actual data operation logic lives in libs/server/Objects/[ObjectName]/[ObjectName]ObjectImpl.cs, dispatched via the operation enum

For new object sub-operations: Add a value to the [ObjectName]Operation enum in libs/server/Objects/[ObjectName]/[ObjectName]Object.cs and handle it in the Operate method's switch statement.

⚠️ Persisted, append-only: the object operation enums (HashOperation, ListOperation,
SetOperation, SortedSetOperation) have explicit values and are serialized to the AOF via
RespInputHeader.SubId. Append the new operation at the end with the next explicit value;
never insert into the middle, reorder, or change an existing value. PersistedEnumStabilityTests
guards these values. The id occupies a full header byte, so up to 256 sub-operations per type are
allowed.

Unified command note

Unified commands (EXISTS, DELETE, TYPE, TTL, EXPIRE, RENAME, etc.) are type-agnostic — they work on both raw string and object values. The RESP handler pattern is the same as string and object commands, but uses UnifiedInput/UnifiedOutput. The storage session layer uses the unified context (unifiedBasicContext), and the callbacks go in libs/server/Storage/Functions/UnifiedStore/.

Blocking command RESP handler pattern

Blocking commands (BLPOP, BRPOP, BLMOVE, BLMPOP, BZPOPMIN, BZPOPMAX, BZMPOP) follow a distinct pattern. They do not use ref storageApi and instead interact with the CollectionItemBroker (libs/server/Objects/ItemBroker/CollectionItemBroker.cs), which manages blocking/waiting behavior:

csharp
private unsafe bool SortedSetBlockingPop(RespCommand command)
{
if (parseState.Count < 2)
return AbortWithWrongNumberOfArguments(command.ToString());
if (!parseState.TryGetTimeout(parseState.Count - 1, out var timeout, out var error))
return AbortWithErrorMessage(error);
var keysBytes = new byte[parseState.Count - 1][];
for (var i = 0; i < keysBytes.Length; i++)
keysBytes[i] = parseState.GetArgSliceByRef(i).ToArray();
var result = storeWrapper.itemBroker.GetCollectionItemAsync(command, keysBytes, this, timeout).Result;
if (!result.Found)
{
WriteNull();
}
else
{
// Write RESP response with result.Key, result.Item, result.Score, etc.
}
return true;
}

Key differences from regular commands:

  • The dispatch in RespServerSession.cs does NOT pass ref storageApi: RespCommand.BZMPOP => SortedSetBlockingMPop(),
  • The handler calls storeWrapper.itemBroker.GetCollectionItemAsync() which blocks (with timeout) until data is available
  • No IGarnetApi method, no storage session method, and no RMW callbacks are needed for the blocking command itself (Steps 5-7 are skipped)
  • The CollectionItemBroker is notified when data is added to a collection (e.g., ZADD calls itemBroker.HandleCollectionUpdate(key)), which wakes up blocked clients
  • When adding a new blocking command, you must also update the TryGetResult method in CollectionItemBroker.cs to map your RespCommand to the correct GarnetObjectType and implement the retrieval logic

Steps 5–7: Storage Layer (skip for admin/non-key commands)

Note: Steps 5, 6, and 7 apply only to commands that read or write key-value data through the store (e.g., SET, GET, DELIFGREATER). Admin commands like DEBUG, PING, CONFIG, etc. handle their logic entirely in the RESP handler (Step 4) and do not need API interface methods, storage session ops, or RMW callbacks. Skip to Step 8 for those. Blocking commands (e.g., BZMPOP) also skip Steps 5-7 — see the blocking command pattern in Step 4.
Note on context types: The unified single-store has three context types: string context (for raw string commands like GET/SET), object context (for collection commands like ZADD/LPUSH), and unified context (for type-agnostic commands like EXISTS/DELETE/TTL/EXPIRE). Most new commands use either the string or object context — the unified context is only for commands that must work across both value types.

Step 5: Add API Interface Method

File: libs/server/API/IGarnetApi.cs

Add method signature to IGarnetApi (read-write) or IGarnetReadApi (read-only):

csharp
// String command:
GarnetStatus MyOperation(PinnedSpanByte key, ref StringInput input, ref StringOutput output);
// Object command:
GarnetStatus MyOperation(PinnedSpanByte key, ref ObjectInput input, ref GarnetObjectStoreOutput output);
// Unified command:
GarnetStatus MyOperation(PinnedSpanByte key, ref UnifiedInput input, ref UnifiedOutput output);

File: libs/server/API/GarnetApi*.cs

Add delegation in the GarnetApi partial struct. The implementation goes in the appropriate partial file based on the context type:

  • GarnetApi.cs — string commands
  • GarnetApiObjectCommands.cs — object commands
  • GarnetApiUnifiedCommands.cs — unified commands
csharp
public GarnetStatus MyOperation(PinnedSpanByte key, ref StringInput input, ref StringOutput output)
=> storageSession.MyOperation(key, ref input, ref output);

⚠️ Caveat: GarnetApi is a generic partial struct: GarnetApi<TStringContext, TObjectContext, TUnifiedContext>. Always add your method to the correct partial file for the context type you're using.

Overloads for programmatic callers: In addition to the primary signature (used by the network handler), you can add simpler overloads for programmatic/embedded callers that avoid forcing them to create the Input/Output structs. For example:

csharp
public GarnetStatus MyOperation(PinnedSpanByte key, double val, out double output)

This overload internally creates the appropriate input/output structs and only returns the desired value to the caller, instead of writing to the output buffer.


Step 6: Implement Storage Session Layer

File: New or existing file in libs/server/Storage/Session/MainStore/ (for string-context ops), libs/server/Storage/Session/ObjectStore/ (for object-context ops), or libs/server/Storage/Session/UnifiedStore/ (for unified-context ops)

String command pattern

This layer wraps Tsavorite API calls. The network path uses a generic context parameter:

csharp
public GarnetStatus MyOperation<TStringContext>(PinnedSpanByte key, ref StringInput input, ref StringOutput output, ref TStringContext context)
where TStringContext : ITsavoriteContext<...>
{
var status = context.RMW((FixedSpanByteKey)key, ref input, ref output);
if (status.IsPending)
CompletePendingForSession(ref status, ref output, ref context);
return GarnetStatus.OK;
}

Object and unified commands follow the same pattern — just substitute the appropriate context, input, and output types:

Context typeInput typeOutput typeHelper method
StringStringInputStringOutputcontext.RMW(...) / context.Read(...)
ObjectObjectInputGarnetObjectStoreOutputRMWObjectStoreOperation(...) / ReadObjectStoreOperation(...)
UnifiedUnifiedInputUnifiedOutputcontext.RMW(...) / context.Read(...)

Programmatic overloads: You can also add simpler overloads for programmatic callers (see Step 5 note). These internally create the input/output structs and return only the desired value.

Object-specific: HandleCollectionUpdate

Object commands use the same pattern as above with ObjectInput/GarnetObjectStoreOutput and the object context.

⚠️ Caveat — `HandleCollectionUpdate`: If your object command modifies a collection (adds/removes elements), call itemBroker.HandleCollectionUpdate(key) after the store operation. This wakes up any clients blocked on that key (e.g., via BZPOPMIN). The actual data logic is implemented in the object class, not in the storage session.

Object implementation

File: libs/server/Objects/[ObjectName]/[ObjectName]ObjectImpl.cs

For object commands, the core logic lives in the object implementation. The Operate method in [ObjectName]Object.cs dispatches to implementation methods based on the operation enum:

csharp
case SortedSetOperation.ZADD:
SortedSetAdd(ref input, ref output.SpanByteAndMemory);
break;

The implementation methods in [ObjectName]ObjectImpl.cs directly manipulate the object's internal data structures (e.g., sortedSet, sortedSetDict for SortedSet).


Step 7: Add RMW/Read Callbacks (if applicable)

File: libs/server/Storage/Functions/MainStore/RMWMethods.cs (string commands) or libs/server/Storage/Functions/UnifiedStore/RMWMethods.cs (unified commands)

If your command uses RMW, you must handle these callbacks:

CallbackWhenPurpose
NeedInitialUpdateKey doesn't existReturn true to create record
InitialUpdaterCreating new recordWrite initial value
NeedCopyUpdateKey exists, record needs copyReturn true to copy-update, false to skip
InPlaceUpdaterKey exists, update in placeModify existing value
CopyUpdaterKey exists, copy to new recordWrite updated value to new record

Add a case RespCommand.MYCMD: to each relevant switch statement.

For Read commands (MainStore): If your command uses Read (not RMW), the read response logic lives in libs/server/Storage/Functions/MainStore/PrivateMethods.cs — add a case to the CopyRespToWithInput method.

File: libs/server/Storage/Functions/MainStore/VarLenInputMethods.cs (string commands) or libs/server/Storage/Functions/UnifiedStore/VarLenInputMethods.cs (unified commands)

If your command writes a value, you must specify the value length:

MethodPurpose
GetRMWInitialFieldInfoSize of value for new records
GetRMWModifiedFieldInfoSize of value for updated records

⚠️ Caveat — RecordType: If your command creates records with a custom RecordType (e.g., for type discrimination), you can set it in InitialUpdater after record initialization:

csharp
var header = logRecord.RecordDataHeader;
header.RecordType = MyManager.MyRecordType;

This works because RecordDataHeader.RecordType has a setter that writes through a raw pointer. No Tsavorite infrastructure changes needed (despite the TODO comments in LogRecord.cs).


Step 8: Command Metadata Registration

8a. Add to SupportedCommand.cs

File: playground/CommandInfoUpdater/SupportedCommand.cs

Add entry following the existing ordering/grouping in the file:

csharp
new("MY.CMD", RespCommand.MYCMD, StoreType.Main),
Note: The file is not strictly alphabetical — entries are grouped by category (e.g., script commands at the end). Follow the existing grouping conventions rather than inserting strictly alphabetically.

For admin/non-key commands (e.g., DEBUG, PING), omit StoreType or use StoreType.None:

csharp
new("DEBUG", RespCommand.DEBUG),

StoreType values: Main (string store), Object (object store), All (both), None (no keys).

8b. Add to GarnetCommandsInfo.json (Garnet-only commands)

File: playground/CommandInfoUpdater/GarnetCommandsInfo.json

Needed for commands that don't exist in standard Redis (e.g., DELIFGREATER, SETIFMATCH), or standard Redis commands whose info you need to override. Standard Redis commands (e.g., DEBUG, GETDEL) normally get their metadata from a running RESP server automatically via the CommandInfoUpdater tool — skip this step and Step 8c for those unless you need to override their info.

Overriding a command that also exists on the baseline server (e.g., OBJECT): the tool merges per sub-command, by name — your GarnetCommandsInfo.json / GarnetCommandsDocs.json entry wins and the baseline server fills in any sub-commands you did not specify. The override replaces the command's base fields wholesale (it is not a field-by-field merge), so include the parent command plus the sub-commands you want to control. This is how OBJECT reports Garnet-accurate sub-command summaries while the tool still generates the rest.

Add a JSON entry:

json
{
"Command": "MYCMD",
"Name": "MY.CMD",
"IsInternal": false,
"Arity": -2,
"Flags": "DenyOom, Write",
"FirstKey": 1,
"LastKey": 1,
"Step": 1,
"AclCategories": "Slow, Write, Garnet",
"KeySpecifications": [
{
"BeginSearch": { "TypeDiscriminator": "BeginSearchIndex", "Index": 1 },
"FindKeys": { "TypeDiscriminator": "FindKeysRange", "LastKey": 0, "KeyStep": 1, "Limit": 0 },
"Flags": "RW, Insert"
}
],
"StoreType": "Main"
}

Key fields:

  • Arity: Positive = exact arg count (including command name); Negative = minimum
  • Flags: ReadOnly, Write, DenyOom, Fast, Admin, NoAuth, Module, etc.
  • AclCategories: Used for ACL permission checks. Use Garnet for Garnet-specific commands
  • KeySpecifications: Drives automatic transaction key locking — no per-command switch needed
  • KeySpec.Flags: RO (read-only), RW (read-write), Access, Insert, Update, Delete

8c. Add to GarnetCommandsDocs.json (Garnet-only commands)

File: playground/CommandInfoUpdater/GarnetCommandsDocs.json

Note: This step is not necessary for internal commands. The main purpose of command docs is to enable client auto-complete for the command.

Add documentation entry:

json
{
"Command": "MYCMD",
"Name": "MY.CMD",
"Summary": "Description of what the command does.",
"Group": "Generic",
"Complexity": "O(1)",
"Arguments": [
{
"TypeDiscriminator": "RespCommandKeyArgument",
"Name": "KEY",
"DisplayText": "key",
"Type": "Key",
"KeySpecIndex": 0
}
]
}

⚠️ Caveat — Group must be a valid `RespCommandGroup` enum value: Bitmap, Cluster, Connection, Generic, Geo, Hash, HyperLogLog, List, Module, PubSub, Scripting, Sentinel, Server, Set, SortedSet, Stream, String, Transactions

Do NOT invent new group names — the JSON deserializer will fail.

8d. Generate the resource JSON files

⚠️ CRITICAL: Never edit `libs/resources/RespCommandsInfo.json` or `libs/resources/RespCommandsDocs.json` directly. These are generated by the CommandInfoUpdater tool.

Steps:

  1. Start a local RESP-compatible server (e.g., Valkey or Redis) — the tool queries it for standard Redis command metadata:

``bash valkey-server --port 6399 ``

  1. Build and run the tool:

``bash cd playground/CommandInfoUpdater dotnet build -f net10.0 dotnet run -f net10.0 --no-build -- --port 6399 --output ../../libs/resources ` (The --port` must match the port of the local RESP server.)

  1. The tool will prompt Would you like to continue? (Y/N) twice (once for info, once for docs). Press Y for both, or pass --yes (see below) to auto-confirm.
  2. Kill the local RESP server afterward.

⚠️ Caveat: By default the tool prompts via Console.ReadKey(), which does NOT work with piped input (echo "Y" | dotnet run ... fails). For non-interactive / scripted runs (including AI agents), pass `--yes` to auto-confirm both prompts; otherwise run it in a real interactive terminal.

⚠️ Caveat: The tool requires a running RESP-compatible server (e.g., Valkey or Redis — not Garnet) to query standard command metadata. For Garnet-only commands, the tool reads from GarnetCommandsInfo.json and GarnetCommandsDocs.json instead.

⚠️ Caveat — unexpected "commands to remove": If the tool reports commands to remove that you did not intend to delete, those commands exist in the resource files but are missing from SupportedCommand.cs. Register them in SupportedCommand.cs (and, for Garnet-only commands, in the override JSONs) rather than reaching for the --ignore flag. Every command Garnet ships should be registered, so a normal run needs no `--ignore`.

See also: `playground/CommandInfoUpdater/README.md` documents the tool in full — the inputs you edit, the per-sub-command override/merge behavior, the baseline server (Docker), the --ignore escape hatch, and how to surgically regenerate a single command.

Step 9: Add ACL Test

File: test/Garnet.test/Resp/ACL/RespCommandTests.cs

⚠️ CRITICAL: The AllCommandsCovered test automatically validates that every RespCommand enum value has a corresponding ACL test. If you add a command without an ACL test, AllCommandsCovered will fail.

Test naming convention:

  • Method name must end with ACLs or ACLsAsync
  • The name (minus the suffix) must match the command name with dots and underscores removed
  • Example: RI.CREATE → RICreateACLsAsync

Pattern (follow SADD for non-idempotent commands):

csharp
[Test]
public async Task MyCommandACLsAsync()
{
int count = 0;
await CheckCommandsAsync(
"MY.CMD",
[DoMyCommandAsync]
).ConfigureAwait(false);
async Task DoMyCommandAsync(GarnetClient client)
{
var val = await client.ExecuteForStringResultAsync("MY.CMD",
[$"key-{count}", "arg1"]).ConfigureAwait(false);
count++;
ClassicAssert.AreEqual("OK", val);
}
}

⚠️ Caveat — Idempotency: The ACL framework calls your command multiple times (for different user/permission combinations). If your command is NOT idempotent (e.g., RI.CREATE fails on duplicate), use a counter to generate unique keys per invocation (see the SADD ACL test pattern).


Step 10: Add Integration Tests

File: Add tests to an existing or new file in test/Garnet.test/:

  • String / Unified commands: Add to test/Garnet.test/RespTests.cs
  • Object commands: Add to test/Garnet.test/Resp[ObjectName]Tests.cs (e.g., RespSortedSetTests.cs)
  • New feature area: Create test/Garnet.test/Resp<Feature>Tests.cs if the command doesn't fit existing test files

Required structure:

csharp
[TestFixture]
public class RespMyFeatureTests : TestBase
{
GarnetServer server;
[SetUp]
public void Setup()
{
TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true);
server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir);
server.Start();
}
[TearDown]
public void TearDown()
{
server.Dispose();
TestUtils.OnTearDown();
}
[Test]
public void MyBasicTest()
{
using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
var db = redis.GetDatabase(0);
var result = db.Execute("MY.CMD", "key", "value");
ClassicAssert.AreEqual("OK", (string)result);
}
}

Note: Test fixtures must inherit from TestBase.

Recommended test cases:

  • Basic success case
  • Error cases (wrong args, wrong type)
  • Duplicate/idempotency behavior
  • DEL/UNLINK interaction (if applicable)
  • Type safety (e.g., string command on object key → WRONGTYPE)

Step 11: Update Website Documentation

File: website/docs/commands/ — choose the appropriate markdown file based on the command category (e.g., garnet-specific.md for Garnet-only commands, api-compatibility.md to mark a standard Redis command as supported).

Add a section documenting the command syntax, description, and response format:

markdown
### **MY.CMD**
#### **Syntax**

MY.CMD key value

Description of what the command does.
#### **Response**
- **Type reply**: Description of the response.

Also mark the command as supported in website/docs/commands/api-compatibility.md if it corresponds to a standard Redis command.


Step 11b: Add Configuration Settings (if needed)

If the command is optional, gated behind a feature flag, or needs a server-side configuration parameter (e.g., DEBUG requires --enable-debug-command), you must wire up a configuration option across four files:

1. Add property to Options class

File: libs/host/Configuration/Options.cs

Add a property with the [Option] attribute (from CommandLineParser):

csharp
[OptionValidation]
[Option("enable-my-feature", Required = false, HelpText = "Enable MY.CMD for 'no', 'local' or 'all' connections")]
public ConnectionProtectionOption EnableMyFeature { get; set; }

The [Option] attribute defines the CLI flag name (kebab-case). Use Required = false for optional settings. Common types: bool, int, string, ConnectionProtectionOption (for no/local/yes connection gating), or custom enums.

2. Map to GarnetServerOptions

File: libs/server/Servers/GarnetServerOptions.cs

Add a matching field:

csharp
/// <summary>
/// Enables MY.CMD
/// </summary>
public ConnectionProtectionOption EnableMyFeature;

Then in Options.GetServerOptions() (in Options.cs), map the property:

csharp
EnableMyFeature = EnableMyFeature,

3. Add default value

File: libs/host/defaults.conf

Add the default in the appropriate section:

json
/* Enable MY.CMD for clients - no/local/yes */
"EnableMyFeature": "no",

4. Check the setting in your RESP handler

Access the setting via storeWrapper.serverOptions:

csharp
if (storeWrapper.serverOptions.EnableMyFeature == ConnectionProtectionOption.No)
{
while (!RespWriteUtils.TryWriteError("ERR command not enabled"u8, ref dcurr, dend))
SendAndReset();
return true;
}

5. Add config tests

File: test/Garnet.test/GarnetServerConfigTests.cs

Test that the setting is parsed correctly from CLI args and config files.


Step 12: Verify Everything

Build

bash
dotnet build Garnet.slnx -c Debug

Format check

bash
dotnet format Garnet.slnx --verify-no-changes

⚠️ Caveat: New files commonly fail with FINALNEWLINE errors. Ensure files do NOT have a trailing newline at the very end. Fix with: perl -pi -e 'chomp if eof' path/to/file.cs

Run your tests

bash
GARNET_TEST_PORT_SLOT=auto dotnet test test/standalone/Garnet.test -f net10.0 -c Debug --filter "FullyQualifiedName~RespMyFeatureTests"

Run ACL coverage test

bash
GARNET_TEST_PORT_SLOT=auto dotnet test test/standalone/Garnet.test -f net10.0 -c Debug --filter "FullyQualifiedName~AllCommandsCovered"

Run broader regression tests

bash
GARNET_TEST_PORT_SLOT=auto dotnet test test/standalone/Garnet.test -f net10.0 -c Debug --filter "FullyQualifiedName~RespTests"

GARNET_TEST_PORT_SLOT=auto keeps this working copy's test ports clear of other working copies that also use a slot; in PowerShell write $env:GARNET_TEST_PORT_SLOT = 'auto'; dotnet test .... See .github/copilot-instructions.md. Check the output for error CS before trusting a pass — with --no-build, or when a compile error surfaces in a project that is not rebuilt, the previously built assembly runs instead.


Transaction Support

For standard commands, transaction key locking is automatic — driven by KeySpecifications in the command metadata JSON. No per-command code is needed in TxnKeyManager.cs.

For custom multi-key operations that don't fit the standard key spec pattern, manually call txnManager.SaveKeyEntryToLock(key, lockType).


Common Caveats and Gotchas

  1. Dot-prefixed commands (e.g., `RI.CREATE`): The RESP wire name uses a dot, but the enum name cannot. The ACL parser, AllCommandsCovered test, and SupportedCommand.cs all need to handle the dot-to-enum mapping. Check ACLParser.cs for normalization logic.
  1. `AllCommandsCovered` is strict: It reflects over ALL RespCommand enum values and ALL entries in RespCommandsInfo.json. Missing either an ACL test or a JSON entry will fail this test.
  1. `NeedCopyUpdate` for create-only commands: If your command should NOT overwrite existing records (like SETNX), return false from NeedCopyUpdate for your command. Otherwise Tsavorite will attempt a copy-update when the record can't be updated in-place.
  1. `VarLenInputMethods.cs` is easy to forget: If your command creates or modifies records via RMW, you must add cases to GetRMWInitialFieldInfo and GetRMWModifiedFieldInfo. Without this, Tsavorite won't allocate the right amount of space for your value.
  1. Resource JSON files are generated, not hand-edited: libs/resources/RespCommandsInfo.json and libs/resources/RespCommandsDocs.json are generated by playground/CommandInfoUpdater. Edit the source files (GarnetCommandsInfo.json, GarnetCommandsDocs.json, SupportedCommand.cs) and run the tool.
  1. `RespCommandGroup` enum is closed: The Group field in docs JSON must match a value in the RespCommandGroup enum (libs/server/Resp/RespCommandDocs.cs). Use Generic if no existing group fits.
  1. File headers: All C# files require // Copyright (c) Microsoft Corporation. / // Licensed under the MIT license.
  1. Test resource usage: Use small values for buffer sizes, cache sizes, etc. in tests. Don't allocate 16MB+ buffers when 64KB will do.

Reference: RI.CREATE Implementation

The RI.CREATE command was the first command implemented following this guide. Key files for reference:

FilePurpose
libs/server/Resp/RangeIndex/RespServerSessionRangeIndex.csRESP handler with option parsing
libs/server/Resp/RangeIndex/RangeIndexManager.csManager pattern for external data
libs/server/Resp/RangeIndex/RangeIndexManager.Index.csFixed-size stub struct in store
libs/server/Storage/Session/MainStore/RangeIndexOps.csStorageSession → RMW flow
test/Garnet.test/RespRangeIndexTests.csIntegration tests
test/Garnet.test/Resp/ACL/RespCommandTests.csACL test (search for RICreateACLsAsync)
All versionsv1.0.1 →