diff --git a/.github/skills/add-garnet-command/SKILL.md b/.github/skills/add-garnet-command/SKILL.md index d421848ff2c..af7c39f891a 100644 --- a/.github/skills/add-garnet-command/SKILL.md +++ b/.github/skills/add-garnet-command/SKILL.md @@ -460,6 +460,8 @@ new("DEBUG", RespCommand.DEBUG), 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 @@ -539,13 +541,17 @@ Do NOT invent new group names — the JSON deserializer will fail. dotnet run -f net10.0 --no-build -- --port 6399 --output ../../libs/resources ``` (The `--port` must match the port of the local RESP server.) -3. The tool will prompt `Would you like to continue? (Y/N)` **twice** (once for info, once for docs). Press `Y` for both. +3. 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. 4. Kill the local RESP server afterward. -**⚠️ Caveat:** The tool uses `Console.ReadKey()` which does NOT work with piped input. You must run it interactively (not via `echo "Y" | dotnet run ...`). For AI agents, use an async shell session and send `Y` keystrokes via interactive input (e.g., `write_bash`). +**⚠️ 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`](../../../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 diff --git a/libs/common/EnumUtils.cs b/libs/common/EnumUtils.cs index 03b1209d80b..4f1b2379862 100644 --- a/libs/common/EnumUtils.cs +++ b/libs/common/EnumUtils.cs @@ -8,6 +8,27 @@ namespace Garnet.common { + /// + /// Specifies an additional description string that maps to an enum value when parsing from a + /// description (e.g. a wire-protocol alias used by a different or newer RESP server version). + /// Aliases affect parsing only; the primary is still used + /// when converting an enum value back to its description. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] + public sealed class EnumDescriptionAliasAttribute : Attribute + { + /// + /// The alias description string. + /// + public string Alias { get; } + + /// + /// Creates a new . + /// + /// The alias description string. + public EnumDescriptionAliasAttribute(string alias) => Alias = alias; + } + /// /// Utilities for enums /// @@ -104,6 +125,14 @@ private static void AddTypeToCache() descToVals.Add(descAttr.Description, new List()); descToVals[descAttr.Description].Add(flagFieldInfo.Name); } + + // Register any parse-only aliases (e.g. wire names used by other RESP server versions) + foreach (var aliasAttr in flagFieldInfo.GetCustomAttributes(typeof(EnumDescriptionAliasAttribute), false).Cast()) + { + if (!descToVals.ContainsKey(aliasAttr.Alias)) + descToVals.Add(aliasAttr.Alias, new List()); + descToVals[aliasAttr.Alias].Add(flagFieldInfo.Name); + } } EnumNameToDescriptionCache.Add(typeof(T), valToDesc); diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index 39f8641e39f..6aa1dcb2a11 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -1469,6 +1469,13 @@ } ] }, + { + "Command": "CLUSTER_MLOG_KEY_TIME", + "Name": "CLUSTER|MLOG_KEY_TIME", + "Summary": "Returns sequence number for provided key.", + "Group": "Cluster", + "Complexity": "O(1)" + }, { "Command": "CLUSTER_MYID", "Name": "CLUSTER|MYID", @@ -1638,13 +1645,6 @@ "Summary": "Processes a forwarded published message from a node in the same shard", "Group": "Cluster", "Complexity": "O(1)" - }, - { - "Command": "CLUSTER_MLOG_KEY_TIME", - "Name": "CLUSTER|MLOG_KEY_TIME", - "Summary": "Returns sequence number for provided key.", - "Group": "Cluster", - "Complexity": "O(1)" } ] }, @@ -5656,6 +5656,86 @@ "Group": "Transactions", "Complexity": "O(1)" }, + { + "Command": "OBJECT", + "Name": "OBJECT", + "Summary": "A container for object introspection commands.", + "Group": "Generic", + "Complexity": "Depends on subcommand.", + "SubCommands": [ + { + "Command": "OBJECT_ENCODING", + "Name": "OBJECT|ENCODING", + "Summary": "Returns the internal encoding of a Garnet object.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_FREQ", + "Name": "OBJECT|FREQ", + "Summary": "Not supported in Garnet: always returns an error, as access frequency (LFU) is not tracked.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_HELP", + "Name": "OBJECT|HELP", + "Summary": "Returns helpful text about the different subcommands.", + "Group": "Generic", + "Complexity": "O(1)" + }, + { + "Command": "OBJECT_IDLETIME", + "Name": "OBJECT|IDLETIME", + "Summary": "Returns the idle time of a Garnet object; always 0, as Garnet does not track per-key idle time.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_REFCOUNT", + "Name": "OBJECT|REFCOUNT", + "Summary": "Returns the reference count of a Garnet object; always 1, as Garnet does not share value objects.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + } + ] + }, { "Command": "PERSIST", "Name": "PERSIST", @@ -6376,6 +6456,22 @@ } ] }, + { + "Command": "RICONFIG", + "Name": "RI.CONFIG", + "Summary": "Returns the configuration of a RangeIndex as alternating field-value pairs.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, { "Command": "RICREATE", "Name": "RI.CREATE", @@ -6454,22 +6550,6 @@ } ] }, - { - "Command": "RICONFIG", - "Name": "RI.CONFIG", - "Summary": "Returns the configuration of a RangeIndex as alternating field-value pairs.", - "Group": "Generic", - "Complexity": "O(1)", - "Arguments": [ - { - "TypeDiscriminator": "RespCommandKeyArgument", - "Name": "KEY", - "DisplayText": "key", - "Type": "Key", - "KeySpecIndex": 0 - } - ] - }, { "Command": "RIDEL", "Name": "RI.DEL", @@ -7371,11 +7451,13 @@ ] }, { - "Command": "SETWITHETAG", - "Name": "SETWITHETAG", - "Summary": "Sets a key-value pair with an ETag. If the key already exists, the value is overwritten and the ETag is incremented. Returns the ETag.", + "Command": "SETNX", + "Name": "SETNX", + "Summary": "Set the string value of a key only when the key doesn't exist.", "Group": "String", "Complexity": "O(1)", + "DocFlags": "Deprecated", + "ReplacedBy": "`SET` with the `NX` argument", "Arguments": [ { "TypeDiscriminator": "RespCommandKeyArgument", @@ -7389,39 +7471,15 @@ "Name": "VALUE", "DisplayText": "value", "Type": "String" - }, - { - "TypeDiscriminator": "RespCommandContainerArgument", - "Name": "EXPIRATION", - "Type": "OneOf", - "ArgumentFlags": "Optional", - "Arguments": [ - { - "TypeDiscriminator": "RespCommandBasicArgument", - "Name": "SECONDS", - "DisplayText": "seconds", - "Type": "Integer", - "Token": "EX" - }, - { - "TypeDiscriminator": "RespCommandBasicArgument", - "Name": "MILLISECONDS", - "DisplayText": "milliseconds", - "Type": "Integer", - "Token": "PX" - } - ] } ] }, { - "Command": "SETNX", - "Name": "SETNX", - "Summary": "Set the string value of a key only when the key doesn't exist.", + "Command": "SETRANGE", + "Name": "SETRANGE", + "Summary": "Overwrites a part of a string value with another by an offset. Creates the key if it doesn't exist.", "Group": "String", - "Complexity": "O(1)", - "DocFlags": "Deprecated", - "ReplacedBy": "`SET` with the `NX` argument", + "Complexity": "O(1), not counting the time taken to copy the new string in place. Usually, this string is very small so the amortized complexity is O(1). Otherwise, complexity is O(M) with M being the length of the value argument.", "Arguments": [ { "TypeDiscriminator": "RespCommandKeyArgument", @@ -7430,6 +7488,12 @@ "Type": "Key", "KeySpecIndex": 0 }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "OFFSET", + "DisplayText": "offset", + "Type": "Integer" + }, { "TypeDiscriminator": "RespCommandBasicArgument", "Name": "VALUE", @@ -7439,11 +7503,11 @@ ] }, { - "Command": "SETRANGE", - "Name": "SETRANGE", - "Summary": "Overwrites a part of a string value with another by an offset. Creates the key if it doesn't exist.", + "Command": "SETWITHETAG", + "Name": "SETWITHETAG", + "Summary": "Sets a key-value pair with an ETag. If the key already exists, the value is overwritten and the ETag is incremented. Returns the ETag.", "Group": "String", - "Complexity": "O(1), not counting the time taken to copy the new string in place. Usually, this string is very small so the amortized complexity is O(1). Otherwise, complexity is O(M) with M being the length of the value argument.", + "Complexity": "O(1)", "Arguments": [ { "TypeDiscriminator": "RespCommandKeyArgument", @@ -7452,17 +7516,33 @@ "Type": "Key", "KeySpecIndex": 0 }, - { - "TypeDiscriminator": "RespCommandBasicArgument", - "Name": "OFFSET", - "DisplayText": "offset", - "Type": "Integer" - }, { "TypeDiscriminator": "RespCommandBasicArgument", "Name": "VALUE", "DisplayText": "value", "Type": "String" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "EXPIRATION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "SECONDS", + "DisplayText": "seconds", + "Type": "Integer", + "Token": "EX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MILLISECONDS", + "DisplayText": "milliseconds", + "Type": "Integer", + "Token": "PX" + } + ] } ] }, @@ -10214,4 +10294,4 @@ } ] } -] +] \ No newline at end of file diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index cabd91dbc41..7dfac70242b 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -812,12 +812,11 @@ "AclCategories": "Admin, Dangerous, Slow, Garnet" }, { - "Command": "CLUSTER_RESERVE", - "Name": "CLUSTER|RESERVE", - "IsInternal": true, - "Arity": 4, - "Flags": "Admin, NoMulti, NoScript", - "AclCategories": "Admin, Dangerous, Garnet" + "Command": "CLUSTER_MLOG_KEY_TIME", + "Name": "CLUSTER|MLOG_KEY_TIME", + "Arity": -2, + "Flags": "Admin, NoMulti, NoScript, ReadOnly", + "AclCategories": "Admin, Slow, Garnet" }, { "Command": "CLUSTER_MTASKS", @@ -880,6 +879,14 @@ "Flags": "Admin, NoAsyncLoading, Stale", "AclCategories": "Admin, Dangerous, Slow" }, + { + "Command": "CLUSTER_RESERVE", + "Name": "CLUSTER|RESERVE", + "IsInternal": true, + "Arity": 4, + "Flags": "Admin, NoMulti, NoScript", + "AclCategories": "Admin, Dangerous, Garnet" + }, { "Command": "CLUSTER_RESET", "Name": "CLUSTER|RESET", @@ -903,14 +910,6 @@ "Flags": "Admin, NoMulti, NoScript", "AclCategories": "Admin, Dangerous, Slow, Garnet" }, - { - "Command": "CLUSTER_SNAPSHOT_DATA", - "Name": "CLUSTER|SNAPSHOT_DATA", - "IsInternal": true, - "Arity": 6, - "Flags": "Admin, NoMulti, NoScript", - "AclCategories": "Admin, Dangerous, Slow, Garnet" - }, { "Command": "CLUSTER_SETCONFIGEPOCH", "Name": "CLUSTER|SET-CONFIG-EPOCH", @@ -961,6 +960,14 @@ "Flags": "Admin, NoMulti, NoScript", "AclCategories": "Admin, Dangerous, Slow, Garnet" }, + { + "Command": "CLUSTER_SNAPSHOT_DATA", + "Name": "CLUSTER|SNAPSHOT_DATA", + "IsInternal": true, + "Arity": 6, + "Flags": "Admin, NoMulti, NoScript", + "AclCategories": "Admin, Dangerous, Slow, Garnet" + }, { "Command": "CLUSTER_SPUBLISH", "Name": "CLUSTER|SPUBLISH", @@ -987,13 +994,6 @@ } ] }, - { - "Command": "CLUSTER_MLOG_KEY_TIME", - "Name": "CLUSTER|MLOG_KEY_TIME", - "Arity": -2, - "Flags": "Admin, Readonly, NoMulti, NoScript", - "AclCategories": "Admin, Slow, Garnet" - }, { "Command": "CLUSTER_SYNC", "Name": "CLUSTER|SYNC", @@ -3459,6 +3459,133 @@ "Flags": "Fast, Loading, NoScript, Stale, AllowBusy", "AclCategories": "Fast, Transaction" }, + { + "Command": "OBJECT", + "Name": "OBJECT", + "Arity": -2, + "AclCategories": "Slow", + "SubCommands": [ + { + "Command": "OBJECT_ENCODING", + "Name": "OBJECT|ENCODING", + "Arity": 3, + "Flags": "ReadOnly", + "FirstKey": 2, + "LastKey": 2, + "Step": 1, + "AclCategories": "KeySpace, Read, Slow", + "Tips": [ + "nondeterministic_output" + ], + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "OBJECT_FREQ", + "Name": "OBJECT|FREQ", + "Arity": 3, + "Flags": "ReadOnly", + "FirstKey": 2, + "LastKey": 2, + "Step": 1, + "AclCategories": "KeySpace, Read, Slow", + "Tips": [ + "nondeterministic_output" + ], + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "OBJECT_HELP", + "Name": "OBJECT|HELP", + "Arity": 2, + "Flags": "Loading, Stale", + "AclCategories": "KeySpace, Slow" + }, + { + "Command": "OBJECT_IDLETIME", + "Name": "OBJECT|IDLETIME", + "Arity": 3, + "Flags": "ReadOnly", + "FirstKey": 2, + "LastKey": 2, + "Step": 1, + "AclCategories": "KeySpace, Read, Slow", + "Tips": [ + "nondeterministic_output" + ], + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "OBJECT_REFCOUNT", + "Name": "OBJECT|REFCOUNT", + "Arity": 3, + "Flags": "ReadOnly", + "FirstKey": 2, + "LastKey": 2, + "Step": 1, + "AclCategories": "KeySpace, Read, Slow", + "Tips": [ + "nondeterministic_output" + ], + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + } + ] + }, { "Command": "PERSIST", "Name": "PERSIST", @@ -3924,14 +4051,14 @@ "StoreType": "All" }, { - "Command": "RICREATE", - "Name": "RI.CREATE", - "Arity": -2, - "Flags": "DenyOom, Write", + "Command": "RICONFIG", + "Name": "RI.CONFIG", + "Arity": 2, + "Flags": "Fast, ReadOnly", "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, Write, Garnet", + "AclCategories": "Fast, Read, Garnet", "KeySpecifications": [ { "BeginSearch": { @@ -3944,20 +4071,20 @@ "KeyStep": 1, "Limit": 0 }, - "Flags": "RW, Insert" + "Flags": "RO, Access" } ], "StoreType": "Main" }, { - "Command": "RICONFIG", - "Name": "RI.CONFIG", - "Arity": 2, - "Flags": "Fast, ReadOnly", + "Command": "RICREATE", + "Name": "RI.CREATE", + "Arity": -2, + "Flags": "DenyOom, Write", "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Fast, Read, Garnet", + "AclCategories": "Slow, Write, Garnet", "KeySpecifications": [ { "BeginSearch": { @@ -3970,7 +4097,7 @@ "KeyStep": 1, "Limit": 0 }, - "Flags": "RO, Access" + "Flags": "RW, Insert" } ], "StoreType": "Main" @@ -4087,7 +4214,7 @@ "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, Read, Garnet", + "AclCategories": "Read, Slow, Garnet", "KeySpecifications": [ { "BeginSearch": { @@ -4113,7 +4240,7 @@ "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, Read, Garnet", + "AclCategories": "Read, Slow, Garnet", "KeySpecifications": [ { "BeginSearch": { @@ -4616,14 +4743,14 @@ "StoreType": "Main" }, { - "Command": "SETWITHETAG", - "Name": "SETWITHETAG", - "Arity": -3, - "Flags": "DenyOom, Write", + "Command": "SETNX", + "Name": "SETNX", + "Arity": 3, + "Flags": "DenyOom, Fast, Write", "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, String, Write", + "AclCategories": "Fast, String, Write", "KeySpecifications": [ { "BeginSearch": { @@ -4636,20 +4763,20 @@ "KeyStep": 1, "Limit": 0 }, - "Flags": "RW, Insert, Update" + "Flags": "OW, Insert" } ], "StoreType": "Main" }, { - "Command": "SETNX", - "Name": "SETNX", - "Arity": 3, - "Flags": "DenyOom, Fast, Write", + "Command": "SETRANGE", + "Name": "SETRANGE", + "Arity": 4, + "Flags": "DenyOom, Write", "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Fast, String, Write", + "AclCategories": "Slow, String, Write", "KeySpecifications": [ { "BeginSearch": { @@ -4662,15 +4789,15 @@ "KeyStep": 1, "Limit": 0 }, - "Flags": "OW, Insert" + "Flags": "RW, Update" } ], "StoreType": "Main" }, { - "Command": "SETRANGE", - "Name": "SETRANGE", - "Arity": 4, + "Command": "SETWITHETAG", + "Name": "SETWITHETAG", + "Arity": -3, "Flags": "DenyOom, Write", "FirstKey": 1, "LastKey": 1, @@ -4688,7 +4815,7 @@ "KeyStep": 1, "Limit": 0 }, - "Flags": "RW, Update" + "Flags": "RW, Update, Insert" } ], "StoreType": "Main" @@ -5374,7 +5501,7 @@ "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Fast, Vector, Write", + "AclCategories": "Fast, Write, Vector", "KeySpecifications": [ { "BeginSearch": { @@ -5574,7 +5701,7 @@ "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, Read, Vector", + "AclCategories": "Read, Slow, Vector", "KeySpecifications": [ { "BeginSearch": { @@ -5649,7 +5776,7 @@ "FirstKey": 1, "LastKey": 1, "Step": 1, - "AclCategories": "Slow, Read, Vector", + "AclCategories": "Read, Slow, Vector", "KeySpecifications": [ { "BeginSearch": { @@ -6876,4 +7003,4 @@ ], "StoreType": "Object" } -] +] \ No newline at end of file diff --git a/libs/server/API/GarnetApiUnifiedCommands.cs b/libs/server/API/GarnetApiUnifiedCommands.cs index 1e8e665556e..cd16a8b8632 100644 --- a/libs/server/API/GarnetApiUnifiedCommands.cs +++ b/libs/server/API/GarnetApiUnifiedCommands.cs @@ -23,6 +23,14 @@ public GarnetStatus MEMORYUSAGE(PinnedSpanByte key, ref UnifiedInput input, ref #endregion + #region OBJECT + + /// + public GarnetStatus OBJECT(PinnedSpanByte key, ref UnifiedInput input, ref UnifiedOutput output) + => storageSession.Read_UnifiedStore(key, ref input, ref output, ref unifiedContext); + + #endregion + #region TYPE /// diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 362e66d0806..1a0aafc3068 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -345,6 +345,20 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi #endregion + #region OBJECT + + /// + /// Inspects the internals of a key (OBJECT ENCODING / REFCOUNT / IDLETIME / FREQ). + /// The specific subcommand is carried in the input header command. + /// + /// Name of the key to inspect + /// + /// + /// GarnetStatus + GarnetStatus OBJECT(PinnedSpanByte key, ref UnifiedInput input, ref UnifiedOutput output); + + #endregion + #region SortedSet Methods /// diff --git a/libs/server/Resp/BasicCommands.cs b/libs/server/Resp/BasicCommands.cs index a0c2500578e..b79b8f71aec 100644 --- a/libs/server/Resp/BasicCommands.cs +++ b/libs/server/Resp/BasicCommands.cs @@ -1491,6 +1491,78 @@ private bool NetworkMemoryUsage(ref TGarnetApi storageApi) return true; } + /// + /// OBJECT ENCODING|REFCOUNT|IDLETIME|FREQ key + /// + private bool NetworkOBJECT(RespCommand subCommand, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count != 1) + { + var subCommandName = subCommand switch + { + RespCommand.OBJECT_ENCODING => "object|encoding", + RespCommand.OBJECT_FREQ => "object|freq", + RespCommand.OBJECT_IDLETIME => "object|idletime", + _ => "object|refcount", + }; + return AbortWithWrongNumberOfArguments(subCommandName); + } + + var key = parseState.GetArgSliceByRef(0); + + var input = new UnifiedInput(subCommand); + var output = GetUnifiedOutput(); + + var status = storageApi.OBJECT(key, ref input, ref output); + + if (status == GarnetStatus.OK) + { + ProcessOutput(output.SpanByteAndMemory); + } + else + { + // Missing key: reply with nil for all OBJECT subcommands. + WriteNull(); + } + + return true; + } + + /// + /// OBJECT HELP + /// + private bool NetworkOBJECTHELP() + { + if (parseState.Count != 0) + { + return AbortWithWrongNumberOfArguments("object|help"); + } + + ReadOnlySpan objectCommands = + [ + "OBJECT [ [value] [opt] ...]. Subcommands are:", + "ENCODING ", + "\tReturn the kind of internal representation used in order to store the value associated with a .", + "FREQ ", + "\tNot supported in Garnet: always returns an error, as access frequency (LFU) is not tracked.", + "IDLETIME ", + "\tReturn the idle time of the . Garnet does not track per-key idle time, so this is always 0.", + "REFCOUNT ", + "\tReturn the number of references of the value associated with the . Garnet does not share value objects, so this is always 1.", + "HELP", + "\tPrints this help.", + ]; + + WriteArrayLength(objectCommands.Length); + foreach (var command in objectCommands) + { + WriteSimpleString(command); + } + + return true; + } + /// /// ASYNC [ON|OFF|BARRIER] /// diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index eb9117cfd5b..9d1ae152a00 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -214,6 +214,7 @@ static partial class CmdStrings public static ReadOnlySpan RESP_ERR_LUA_DISABLED => "ERR This instance has Lua scripting support disabled"u8; public static ReadOnlySpan RESP_ERR_GENERIC_WRONG_ARGUMENTS => "ERR wrong number of arguments for 'config|set' command"u8; public static ReadOnlySpan RESP_ERR_GENERIC_NOSUCHKEY => "ERR no such key"u8; + public static ReadOnlySpan RESP_ERR_OBJECT_FREQ_UNSUPPORTED => "ERR OBJECT FREQ is not supported: Garnet does not track access frequency (no LFU maxmemory policy)."u8; public static ReadOnlySpan RESP_ERR_GENERIC_NESTED_MULTI => "ERR MULTI calls can not be nested"u8; public static ReadOnlySpan RESP_ERR_GENERIC_EXEC_WO_MULTI => "ERR EXEC without MULTI"u8; public static ReadOnlySpan RESP_ERR_GENERIC_DISCARD_WO_MULTI => "ERR DISCARD without MULTI"u8; @@ -369,6 +370,12 @@ static partial class CmdStrings public static ReadOnlySpan rangeindext => "rangeindex"u8; public static ReadOnlySpan none => "none"u8; + // OBJECT ENCODING result strings + public static ReadOnlySpan raw => "raw"u8; + public static ReadOnlySpan hashtable => "hashtable"u8; + public static ReadOnlySpan quicklist => "quicklist"u8; + public static ReadOnlySpan skiplist => "skiplist"u8; + /// /// Register object types /// diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index 4a3f07ba099..d235f88411f 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -63,6 +63,10 @@ public enum RespCommand : ushort LRANGE, MEMORY_USAGE, MGET, + OBJECT_ENCODING, + OBJECT_FREQ, + OBJECT_IDLETIME, + OBJECT_REFCOUNT, PEXPIRETIME, PFCOUNT, PTTL, @@ -346,6 +350,10 @@ public enum RespCommand : ushort MEMORY, // MEMORY_USAGE is a read-only command, so moved up + OBJECT, + OBJECT_HELP, + // OBJECT_ENCODING/FREQ/IDLETIME/REFCOUNT are read-only commands, so moved up + CONFIG, CONFIG_GET, CONFIG_REWRITE, diff --git a/libs/server/Resp/Parser/RespCommandHashLookup.cs b/libs/server/Resp/Parser/RespCommandHashLookup.cs index 3bea7358aac..b35afda0caf 100644 --- a/libs/server/Resp/Parser/RespCommandHashLookup.cs +++ b/libs/server/Resp/Parser/RespCommandHashLookup.cs @@ -97,6 +97,9 @@ private struct CommandEntry private static CommandEntry[] memorySubTable; private static int memorySubTableMask; + private static CommandEntry[] objectSubTable; + private static int objectSubTableMask; + private static CommandEntry[] bitopSubTable; private static int bitopSubTableMask; @@ -127,6 +130,7 @@ internal static void Initialize() moduleSubTable = BuildSubTable(ModuleSubcommands, out moduleSubTableMask); pubsubSubTable = BuildSubTable(PubsubSubcommands, out pubsubSubTableMask); memorySubTable = BuildSubTable(MemorySubcommands, out memorySubTableMask); + objectSubTable = BuildSubTable(ObjectSubcommands, out objectSubTableMask); bitopSubTable = BuildSubTable(BitopSubcommands, out bitopSubTableMask); // Validate all subcommand tables are round-trip correct @@ -141,6 +145,7 @@ internal static void Initialize() ValidateSubTable(RespCommand.MODULE, ModuleSubcommands, moduleSubTable, moduleSubTableMask); ValidateSubTable(RespCommand.PUBSUB, PubsubSubcommands, pubsubSubTable, pubsubSubTableMask); ValidateSubTable(RespCommand.MEMORY, MemorySubcommands, memorySubTable, memorySubTableMask); + ValidateSubTable(RespCommand.OBJECT, ObjectSubcommands, objectSubTable, objectSubTableMask); ValidateSubTable(RespCommand.BITOP, BitopSubcommands, bitopSubTable, bitopSubTableMask); // Validate primary table: every inserted command must round-trip via Lookup @@ -284,6 +289,7 @@ public static RespCommand LookupSubcommand(RespCommand parent, byte* name, int l RespCommand.MODULE => (moduleSubTable, moduleSubTableMask), RespCommand.PUBSUB => (pubsubSubTable, pubsubSubTableMask), RespCommand.MEMORY => (memorySubTable, memorySubTableMask), + RespCommand.OBJECT => (objectSubTable, objectSubTableMask), RespCommand.BITOP => (bitopSubTable, bitopSubTableMask), _ => (null, 0) }; diff --git a/libs/server/Resp/Parser/RespCommandHashLookupData.cs b/libs/server/Resp/Parser/RespCommandHashLookupData.cs index 3700079eb37..bc028478f8b 100644 --- a/libs/server/Resp/Parser/RespCommandHashLookupData.cs +++ b/libs/server/Resp/Parser/RespCommandHashLookupData.cs @@ -312,6 +312,7 @@ void Add(string name, RespCommand cmd, bool hasSub = false) Add("MODULE", RespCommand.MODULE, hasSub: true); Add("PUBSUB", RespCommand.PUBSUB, hasSub: true); Add("MEMORY", RespCommand.MEMORY, hasSub: true); + Add("OBJECT", RespCommand.OBJECT, hasSub: true); } #endregion @@ -451,6 +452,15 @@ private static readonly (string Name, RespCommand Command)[] MemorySubcommands = ("USAGE", RespCommand.MEMORY_USAGE), ]; + private static readonly (string Name, RespCommand Command)[] ObjectSubcommands = + [ + ("ENCODING", RespCommand.OBJECT_ENCODING), + ("FREQ", RespCommand.OBJECT_FREQ), + ("HELP", RespCommand.OBJECT_HELP), + ("IDLETIME", RespCommand.OBJECT_IDLETIME), + ("REFCOUNT", RespCommand.OBJECT_REFCOUNT), + ]; + private static readonly (string Name, RespCommand Command)[] BitopSubcommands = [ ("AND", RespCommand.BITOP_AND), diff --git a/libs/server/Resp/RespCommandDataProvider.cs b/libs/server/Resp/RespCommandDataProvider.cs index 062bd525646..18c61958a7e 100644 --- a/libs/server/Resp/RespCommandDataProvider.cs +++ b/libs/server/Resp/RespCommandDataProvider.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using Garnet.common; @@ -106,6 +107,9 @@ internal class DefaultRespCommandsDataProvider : IRespCommandsDataProvide private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, + // Emit literal characters (e.g. ' + `) rather than \uXXXX escapes so the generated + // resource JSON files stay human-readable and diff-friendly. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, Converters = { new JsonStringEnumConverter(), new KeySpecConverter(), new RespCommandArgumentConverter() } }; @@ -134,7 +138,18 @@ public bool TryImportRespCommandsData(string path, IStreamProvider streamProvide var tmpRespCommandsData = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var data in respCommandsData) { - tmpRespCommandsData.Add(data.Name, data); + if (string.IsNullOrWhiteSpace(data.Name)) + { + logger?.LogError("Encountered a command entry with a missing or empty 'Name' field (Command: {command}) while parsing resp command data file (Path: {path}).", data.Command, path); + return false; + } + + if (!tmpRespCommandsData.TryAdd(data.Name, data)) + { + logger?.LogError("Encountered a duplicate command 'Name' ({name}) while parsing resp command data file (Path: {path}).", data.Name, path); + return false; + } + if (data.SubCommands != null) { foreach (var subCommand in data.SubCommands) diff --git a/libs/server/Resp/RespCommandInfoFlags.cs b/libs/server/Resp/RespCommandInfoFlags.cs index 93992911071..5d9b3e09a06 100644 --- a/libs/server/Resp/RespCommandInfoFlags.cs +++ b/libs/server/Resp/RespCommandInfoFlags.cs @@ -3,6 +3,7 @@ using System; using System.ComponentModel; +using Garnet.common; namespace Garnet.server { @@ -48,6 +49,7 @@ public enum RespCommandFlags [Description("skip_monitor")] SkipMonitor = 1 << 16, [Description("skip_slowlog")] + [EnumDescriptionAlias("skip_commandlog")] SkipSlowLog = 1 << 17, [Description("stale")] Stale = 1 << 18, diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index 8b42df2bbb8..c5dbe3c911b 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -1048,6 +1048,11 @@ private bool ProcessOtherCommands(RespCommand command, ref TGarnetAp { RespCommand.AUTH => NetworkAUTH(), RespCommand.MEMORY_USAGE => NetworkMemoryUsage(ref storageApi), + RespCommand.OBJECT_ENCODING => NetworkOBJECT(RespCommand.OBJECT_ENCODING, ref storageApi), + RespCommand.OBJECT_FREQ => NetworkOBJECT(RespCommand.OBJECT_FREQ, ref storageApi), + RespCommand.OBJECT_IDLETIME => NetworkOBJECT(RespCommand.OBJECT_IDLETIME, ref storageApi), + RespCommand.OBJECT_REFCOUNT => NetworkOBJECT(RespCommand.OBJECT_REFCOUNT, ref storageApi), + RespCommand.OBJECT_HELP => NetworkOBJECTHELP(), RespCommand.CLIENT_ID => NetworkCLIENTID(), RespCommand.CLIENT_INFO => NetworkCLIENTINFO(), RespCommand.CLIENT_LIST => NetworkCLIENTLIST(), diff --git a/libs/server/Storage/Functions/UnifiedStore/ReadMethods.cs b/libs/server/Storage/Functions/UnifiedStore/ReadMethods.cs index 42fa2f52975..32afca292de 100644 --- a/libs/server/Storage/Functions/UnifiedStore/ReadMethods.cs +++ b/libs/server/Storage/Functions/UnifiedStore/ReadMethods.cs @@ -32,6 +32,10 @@ public bool Reader(in TSourceLogRecord srcLogRecord, ref Unifi RespCommand.MIGRATE => HandleMigrate(in srcLogRecord, (int)input.arg1, ref output), RespCommand.MEMORY_USAGE => HandleMemoryUsage(in srcLogRecord, ref output), RespCommand.TYPE => HandleType(in srcLogRecord, ref output), + RespCommand.OBJECT_ENCODING => HandleObjectEncoding(in srcLogRecord, ref output), + RespCommand.OBJECT_REFCOUNT => HandleObjectRefCount(ref output), + RespCommand.OBJECT_IDLETIME => HandleObjectIdleTime(ref output), + RespCommand.OBJECT_FREQ => HandleObjectFreq(ref output), RespCommand.TTL or RespCommand.PTTL => HandleTtl(in srcLogRecord, ref output, cmd == RespCommand.PTTL), RespCommand.EXPIRETIME or @@ -41,6 +45,61 @@ RespCommand.EXPIRETIME or }; } + private bool HandleObjectEncoding(in TSourceLogRecord srcLogRecord, + ref UnifiedOutput output) where TSourceLogRecord : ISourceLogRecord + { + using var writer = new RespMemoryWriter(functionsState.respProtocolVersion, ref output.SpanByteAndMemory); + + ReadOnlySpan encoding; + if (srcLogRecord.DataHeader.ValueIsObject) + { + // Garnet does not use the compact listpack/intset encodings; collections are stored as + // hashtable / linked-list / skiplist structures. Report the closest standard encoding name: + // hashtable (hash/set), quicklist (list), skiplist (sorted set). + encoding = srcLogRecord.ValueObject switch + { + SortedSetObject => CmdStrings.skiplist, + ListObject => CmdStrings.quicklist, + SetObject => CmdStrings.hashtable, + HashObject => CmdStrings.hashtable, + _ => CmdStrings.hashtable, + }; + } + else + { + // Garnet stores raw string values as bytes (inline or overflow); it has no native + // integer or embedded-string representation, so report "raw". + encoding = CmdStrings.raw; + } + + writer.WriteBulkString(encoding); + return true; + } + + private bool HandleObjectRefCount(ref UnifiedOutput output) + { + // Garnet does not share value objects, so the reference count is always 1. + using var writer = new RespMemoryWriter(functionsState.respProtocolVersion, ref output.SpanByteAndMemory); + writer.WriteInt64(1); + return true; + } + + private bool HandleObjectIdleTime(ref UnifiedOutput output) + { + // Garnet does not track per-key LRU idle time. + using var writer = new RespMemoryWriter(functionsState.respProtocolVersion, ref output.SpanByteAndMemory); + writer.WriteInt64(0); + return true; + } + + private bool HandleObjectFreq(ref UnifiedOutput output) + { + // Garnet does not implement an LFU maxmemory policy, so access frequency is not tracked. + using var writer = new RespMemoryWriter(functionsState.respProtocolVersion, ref output.SpanByteAndMemory); + writer.WriteError(CmdStrings.RESP_ERR_OBJECT_FREQ_UNSUPPORTED); + return true; + } + private bool HandleMemoryUsage(in TSourceLogRecord srcLogRecord, ref UnifiedOutput output) where TSourceLogRecord : ISourceLogRecord { diff --git a/playground/CommandInfoUpdater/CommandDocsUpdater.cs b/playground/CommandInfoUpdater/CommandDocsUpdater.cs index 9d77a94df1c..341a6436595 100644 --- a/playground/CommandInfoUpdater/CommandDocsUpdater.cs +++ b/playground/CommandInfoUpdater/CommandDocsUpdater.cs @@ -27,7 +27,7 @@ public class CommandDocsUpdater /// Logger /// True if file generated successfully public static bool TryUpdateCommandDocs(string outputDir, int respServerPort, IPAddress respServerHost, - IEnumerable ignoreCommands, IReadOnlyDictionary updatedCommandsInfo, bool force, ILogger logger) + IEnumerable ignoreCommands, IReadOnlyDictionary updatedCommandsInfo, bool force, ILogger logger, bool autoConfirm = false) { logger.LogInformation("Attempting to update RESP commands docs..."); @@ -53,7 +53,7 @@ public static bool TryUpdateCommandDocs(string outputDir, int respServerPort, IP var (commandsToAdd, commandsToRemove) = CommonUtils.GetCommandsToAddAndRemove(existingCommandsDocs, ignoreCommands, internalSubCommands); - if (!CommonUtils.GetUserConfirmation(commandsToAdd, commandsToRemove, logger)) + if (!CommonUtils.GetUserConfirmation(commandsToAdd, commandsToRemove, logger, autoConfirm)) { logger.LogInformation("User cancelled update operation."); return false; diff --git a/playground/CommandInfoUpdater/CommandInfoUpdater.cs b/playground/CommandInfoUpdater/CommandInfoUpdater.cs index ca9cc0947af..f8e2079c8df 100644 --- a/playground/CommandInfoUpdater/CommandInfoUpdater.cs +++ b/playground/CommandInfoUpdater/CommandInfoUpdater.cs @@ -31,7 +31,7 @@ public class CommandInfoUpdater /// Updated command info data /// True if file generated successfully public static bool TryUpdateCommandInfo(string outputDir, int respServerPort, IPAddress respServerHost, - IEnumerable ignoreCommands, bool force, ILogger logger, out IReadOnlyDictionary updatedCommandsInfo) + IEnumerable ignoreCommands, bool force, ILogger logger, out IReadOnlyDictionary updatedCommandsInfo, bool autoConfirm = false) { logger.LogInformation("Attempting to update RESP commands info..."); updatedCommandsInfo = default; @@ -47,7 +47,7 @@ public static bool TryUpdateCommandInfo(string outputDir, int respServerPort, IP var (commandsToAdd, commandsToRemove) = CommonUtils.GetCommandsToAddAndRemove(existingCommandsInfo, ignoreCommands, null); - if (!CommonUtils.GetUserConfirmation(commandsToAdd, commandsToRemove, logger)) + if (!CommonUtils.GetUserConfirmation(commandsToAdd, commandsToRemove, logger, autoConfirm)) { logger.LogInformation("User cancelled update operation."); return false; @@ -215,7 +215,7 @@ private static unsafe bool TryGetCommandsInfo(string[] commandsToQuery, int resp // Parse each command's command info for (var cmdIdx = 0; cmdIdx < cmdCount; cmdIdx++) { - if (!RespCommandInfoParser.TryReadFromResp(ref ptr, end, supportedCommands, out var command)) + if (!RespCommandInfoParser.TryReadFromResp(ref ptr, end, supportedCommands, out var command, logger: logger)) { logger.LogError("Unable to read RESP command info from server for command {command}", commandsToQuery[cmdIdx]); return false; diff --git a/playground/CommandInfoUpdater/CommonUtils.cs b/playground/CommandInfoUpdater/CommonUtils.cs index aa669dbbb8e..3eec5453134 100644 --- a/playground/CommandInfoUpdater/CommonUtils.cs +++ b/playground/CommandInfoUpdater/CommonUtils.cs @@ -137,6 +137,9 @@ .. supportedCommand.SubCommands // Find commands / sub-commands to remove foreach (var existingCommand in existingCommandsInfo) { + // Skip commands in the ignore list so they are neither added nor removed + if (commandsToIgnore != null && commandsToIgnore.Contains(existingCommand.Key)) continue; + var existingSubCommands = existingCommand.Value.SubCommands; // If supported commands do not contain existing parent command, add it to the list and indicate parent command should be removed @@ -149,11 +152,14 @@ .. supportedCommand.SubCommands // If supported commands contain existing parent command and no sub-commands are indicated in existing commands, no sub-commands to remove if (existingSubCommands == null) continue; - // Set sub-commands to remove as the difference between supported sub-commands and existing command's sub-commands + // Set sub-commands to remove as the difference between supported sub-commands and existing command's sub-commands, + // excluding any sub-commands in the ignore list var subCommandsToRemove = (supportedCommands[existingCommand.Key].SubCommands == null ? existingSubCommands : existingSubCommands.Where(sc => !supportedCommands[existingCommand.Key].SubCommands!.ContainsKey(sc.Name))) + .Where(sc => !subCommandsToIgnore.TryGetValue(existingCommand.Key, out var ignoredSubCommands) || + !ignoredSubCommands.Contains(sc.Name)) .Select(sc => new SupportedCommand(sc.Name)) .ToArray(); @@ -177,7 +183,7 @@ .. supportedCommand.SubCommands /// Logger /// True if user wishes to continue, false otherwise internal static bool GetUserConfirmation(IDictionary commandsToAdd, IDictionary commandsToRemove, - ILogger logger) + ILogger logger, bool autoConfirm = false) { var logCommandsToAdd = commandsToAdd.Where(kvp => kvp.Value).Select(c => c.Key.Command).ToList(); var logSubCommandsToAdd = commandsToAdd.Where(c => c.Key.SubCommands != null) @@ -204,6 +210,12 @@ internal static bool GetUserConfirmation(IDictionary com return false; } + if (autoConfirm) + { + logger.LogInformation("Auto-confirm (--yes) enabled, proceeding without prompting."); + return true; + } + logger.LogCritical("Would you like to continue? (Y/N)"); var inputChar = Console.ReadKey(); while (true) diff --git a/playground/CommandInfoUpdater/GarnetCommandsDocs.json b/playground/CommandInfoUpdater/GarnetCommandsDocs.json index ead6ba4fcf2..c7166b32219 100644 --- a/playground/CommandInfoUpdater/GarnetCommandsDocs.json +++ b/playground/CommandInfoUpdater/GarnetCommandsDocs.json @@ -1,4 +1,84 @@ [ + { + "Command": "OBJECT", + "Name": "OBJECT", + "Summary": "A container for object introspection commands.", + "Group": "Generic", + "Complexity": "Depends on subcommand.", + "SubCommands": [ + { + "Command": "OBJECT_ENCODING", + "Name": "OBJECT|ENCODING", + "Summary": "Returns the internal encoding of a Garnet object.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_FREQ", + "Name": "OBJECT|FREQ", + "Summary": "Not supported in Garnet: always returns an error, as access frequency (LFU) is not tracked.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_HELP", + "Name": "OBJECT|HELP", + "Summary": "Returns helpful text about the different subcommands.", + "Group": "Generic", + "Complexity": "O(1)" + }, + { + "Command": "OBJECT_IDLETIME", + "Name": "OBJECT|IDLETIME", + "Summary": "Returns the idle time of a Garnet object; always 0, as Garnet does not track per-key idle time.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "OBJECT_REFCOUNT", + "Name": "OBJECT|REFCOUNT", + "Summary": "Returns the reference count of a Garnet object; always 1, as Garnet does not share value objects.", + "Group": "Generic", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + } + ] + }, { "Command": "EXPDELSCAN", "Name": "EXPDELSCAN", @@ -1517,6 +1597,13 @@ "Summary": "Sent by primary to replica to force to FLUSH its database", "Group": "Cluster", "Complexity": "O(1)" + }, + { + "Command": "CLUSTER_MLOG_KEY_TIME", + "Name": "CLUSTER|MLOG_KEY_TIME", + "Summary": "Returns sequence number for provided key.", + "Group": "Cluster", + "Complexity": "O(1)" } ] }, @@ -2123,6 +2210,7 @@ }, { "Command": "RIDEL", + "Name": "RI.DEL", "Summary": "Deletes an entry from a RangeIndex.", "Group": "Generic", "Complexity": "O(log N)", @@ -2160,6 +2248,7 @@ }, { "Command": "RIGET", + "Name": "RI.GET", "Summary": "Gets the value of an entry in a RangeIndex.", "Group": "Generic", "Complexity": "O(log N)", @@ -2197,6 +2286,7 @@ }, { "Command": "RIRANGE", + "Name": "RI.RANGE", "Summary": "Returns entries in a key range from a RangeIndex.", "Group": "Generic", "Complexity": "O(log N + M) where M is the number of entries returned", @@ -2338,5 +2428,203 @@ "Type": "String" } ] + }, + { + "Command": "VADD", + "Name": "VADD", + "Summary": "Add a new element into the vector set.", + "Group": "Vector", + "Complexity": "O(log(N))", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VCARD", + "Name": "VCARD", + "Summary": "Return the number of elements in a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VDIM", + "Name": "VDIM", + "Summary": "Return the number of dimensions in a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VEMB", + "Name": "VEMB", + "Summary": "Return the approximate vector associated with an element in a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VGETATTR", + "Name": "VGETATTR", + "Summary": "Return the JSON attributes associated with the element in the vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VINFO", + "Name": "VINFO", + "Summary": "Return details about a vector set, including dimensions, quantization, and structure.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VISMEMBER", + "Name": "VISMEMBER", + "Summary": "Determines whether a member belongs to vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ELEMENT", + "DisplayText": "element", + "Type": "String" + } + ] + }, + { + "Command": "VLINKS", + "Name": "VLINKS", + "Summary": "Return the neighbors of an element in a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VRANDMEMBER", + "Name": "VRANDMEMBER", + "Summary": "Return some number of random elements from a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VREM", + "Name": "VREM", + "Summary": "Remove an element from a vector set.", + "Group": "Vector", + "Complexity": "O(log(N))", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VSETATTR", + "Name": "VSETATTR", + "Summary": "Store attributes alongside a member of a vector set.", + "Group": "Vector", + "Complexity": "O(1)", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] + }, + { + "Command": "VSIM", + "Name": "VSIM", + "Summary": "Return elements similar to a given vector or existing element of a vector set.", + "Group": "Vector", + "Complexity": "O(log(N))", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + } + ] } ] \ No newline at end of file diff --git a/playground/CommandInfoUpdater/GarnetCommandsInfo.json b/playground/CommandInfoUpdater/GarnetCommandsInfo.json index 9be10629348..9d67fd288a0 100644 --- a/playground/CommandInfoUpdater/GarnetCommandsInfo.json +++ b/playground/CommandInfoUpdater/GarnetCommandsInfo.json @@ -25,6 +25,19 @@ "Tips": null, "KeySpecifications": null, "SubCommands": [ + { + "Command": "CLUSTER_ADVANCE_TIME", + "Name": "CLUSTER|ADVANCE_TIME", + "IsInternal": true, + "Arity": 2, + "Flags": "Admin, NoMulti, NoScript", + "FirstKey": 0, + "LastKey": 0, + "Step": 0, + "AclCategories": "Admin, Dangerous, Slow, Garnet", + "KeySpecifications": null, + "SubCommands": null + }, { "Command": "CLUSTER_APPENDLOG", "Name": "CLUSTER|APPENDLOG", @@ -205,13 +218,12 @@ { "Command": "CLUSTER_MLOG_KEY_TIME", "Name": "CLUSTER|MLOG_KEY_TIME", - "IsInternal": true, "Arity": -2, - "Flags": "Admin, NoScript, NoMulti", + "Flags": "Admin, NoScript, NoMulti, ReadOnly", "FirstKey": 0, "LastKey": 0, "Step": 0, - "AclCategories": "Admin, Dangerous, Slow, Garnet", + "AclCategories": "Admin, Slow, Garnet", "KeySpecifications": null, "SubCommands": null }, @@ -350,6 +362,14 @@ "Arity": 4, "Flags": "Admin, NoMulti, NoScript", "AclCategories": "Admin, Dangerous, Slow, Garnet" + }, + { + "Command": "CLUSTER_RESERVE", + "Name": "CLUSTER|RESERVE", + "IsInternal": true, + "Arity": 4, + "Flags": "Admin, NoMulti, NoScript", + "AclCategories": "Admin, Dangerous, Garnet" } ] }, @@ -1554,6 +1574,7 @@ }, { "Command": "RIGET", + "Name": "RI.GET", "IsInternal": false, "Arity": 3, "Flags": "Fast, ReadOnly", @@ -1609,6 +1630,7 @@ }, { "Command": "RIRANGE", + "Name": "RI.RANGE", "IsInternal": false, "Arity": -4, "Flags": "ReadOnly", @@ -1689,5 +1711,305 @@ } ], "StoreType": "Main" + }, + { + "Command": "VADD", + "Name": "VADD", + "Arity": -1, + "Flags": "DenyOom, Write, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Write, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Insert" + } + ] + }, + { + "Command": "VCARD", + "Name": "VCARD", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VDIM", + "Name": "VDIM", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VEMB", + "Name": "VEMB", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VGETATTR", + "Name": "VGETATTR", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VINFO", + "Name": "VINFO", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VISMEMBER", + "Name": "VISMEMBER", + "Arity": 3, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VLINKS", + "Name": "VLINKS", + "Arity": -1, + "Flags": "Fast, ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Read, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VRANDMEMBER", + "Name": "VRANDMEMBER", + "Arity": -1, + "Flags": "ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Read, Slow, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] + }, + { + "Command": "VREM", + "Name": "VREM", + "Arity": -1, + "Flags": "Write, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Slow, Write, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Delete" + } + ] + }, + { + "Command": "VSETATTR", + "Name": "VSETATTR", + "Arity": -1, + "Flags": "Fast, Write, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, Write, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Insert" + } + ] + }, + { + "Command": "VSIM", + "Name": "VSIM", + "Arity": -1, + "Flags": "ReadOnly, Module", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Read, Slow, Vector", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO" + } + ] } ] \ No newline at end of file diff --git a/playground/CommandInfoUpdater/Options.cs b/playground/CommandInfoUpdater/Options.cs index 57f6e46e163..6e0ef2fa814 100644 --- a/playground/CommandInfoUpdater/Options.cs +++ b/playground/CommandInfoUpdater/Options.cs @@ -21,5 +21,8 @@ public class Options [Option('i', "ignore", Required = false, Separator = ',', HelpText = "Command names to ignore (comma separated)")] public IEnumerable IgnoreCommands { get; set; } + + [Option('y', "yes", Required = false, Default = false, HelpText = "Automatically answer 'yes' to all confirmation prompts (for non-interactive / scripted runs)")] + public bool AutoConfirm { get; set; } } } \ No newline at end of file diff --git a/playground/CommandInfoUpdater/Program.cs b/playground/CommandInfoUpdater/Program.cs index 6ab35ff1b9a..84a7f63239d 100644 --- a/playground/CommandInfoUpdater/Program.cs +++ b/playground/CommandInfoUpdater/Program.cs @@ -58,11 +58,11 @@ static void Main(string[] args) } if (!CommandInfoUpdater.CommandInfoUpdater.TryUpdateCommandInfo(config.OutputDir, config.RespServerPort, - localRedisHost, config.IgnoreCommands, config.Force, logger, out var updatedCommandsInfo)) + localRedisHost, config.IgnoreCommands, config.Force, logger, out var updatedCommandsInfo, config.AutoConfirm)) return; CommandDocsUpdater.TryUpdateCommandDocs(config.OutputDir, config.RespServerPort, - localRedisHost, config.IgnoreCommands, updatedCommandsInfo, config.Force, logger); + localRedisHost, config.IgnoreCommands, updatedCommandsInfo, config.Force, logger, config.AutoConfirm); } static void DisplayHelp(ParserResult result, IEnumerable errs) diff --git a/playground/CommandInfoUpdater/README.md b/playground/CommandInfoUpdater/README.md new file mode 100644 index 00000000000..b17d1089ee3 --- /dev/null +++ b/playground/CommandInfoUpdater/README.md @@ -0,0 +1,232 @@ +# CommandInfoUpdater + +A developer tool that (re)generates Garnet's two command‑metadata resource files: + +- `libs/resources/RespCommandsInfo.json` — command **info** (arity, flags, ACL categories, key specs, store type, tips). Drives `COMMAND`, `COMMAND INFO`, `COMMAND COUNT`, ACL category checks, and automatic transaction key‑locking. +- `libs/resources/RespCommandsDocs.json` — command **docs** (summary, group, complexity, arguments). Drives `COMMAND DOCS` and client auto‑complete. + +> ⚠️ **Never hand‑edit `libs/resources/RespCommandsInfo.json` or `libs/resources/RespCommandsDocs.json`.** They are generated. Edit the tool inputs described below and re‑run the tool. + +The tool fills in metadata for **standard** commands by querying a live RESP‑compatible server (we use **Valkey**), and fills in metadata for **Garnet‑only** commands (and any per‑command/per‑subcommand overrides) from two JSON override files that ship with the tool. + +--- + +## TL;DR — full run + +```bash +# 1. Start the baseline RESP server (Valkey) on port 6399 +docker run -d --rm --name garnet-cmdinfo-valkey -p 6399:6379 valkey/valkey:8.1 +docker exec garnet-cmdinfo-valkey valkey-cli -p 6379 PING # -> PONG + +# 2. Edit the inputs you need (see "Inputs you edit" below): +# - playground/CommandInfoUpdater/SupportedCommand.cs +# - playground/CommandInfoUpdater/GarnetCommandsInfo.json (Garnet-only / info overrides) +# - playground/CommandInfoUpdater/GarnetCommandsDocs.json (Garnet-only / docs overrides) + +# 3. Build and run the tool (rebuild is REQUIRED after editing any input – see note below) +cd playground/CommandInfoUpdater +dotnet build -f net10.0 +dotnet run -f net10.0 --no-build -- \ + --port 6399 --host 127.0.0.1 \ + --output ../../libs/resources + +# The tool prompts "Would you like to continue? (Y/N)" TWICE (once for info, once for docs). +# Press Y for both — or pass --yes to auto-confirm for non-interactive runs. + +# 4. Stop the baseline server +docker stop garnet-cmdinfo-valkey + +# 5. Rebuild Garnet so the server embeds the regenerated resources, then review the diff +cd ../.. +dotnet build Garnet.slnx -c Debug -f net10.0 +git diff --stat libs/resources/ +``` + +--- + +## Baseline RESP server (Docker) + +The tool queries a running RESP server for the metadata of **standard** commands (via `COMMAND INFO` and `COMMAND DOCS`). Use **Valkey** (not Garnet — Garnet is the thing being described, and not plain Redis): + +```bash +# Start (foreground logs: drop -d). --rm auto-removes on stop. Host 6399 -> container 6379. +docker run -d --rm --name garnet-cmdinfo-valkey -p 6399:6379 valkey/valkey:8.1 + +# Verify it is up +docker exec garnet-cmdinfo-valkey valkey-cli -p 6379 PING # -> PONG +docker exec garnet-cmdinfo-valkey valkey-cli -p 6379 INFO server | grep version + +# Stop when done +docker stop garnet-cmdinfo-valkey +``` + +The `--port` you pass to the tool must match the **host** port you published (`6399` above). + +--- + +## Inputs you edit + +All three inputs are **compiled/embedded into the tool**, so you must **rebuild the tool** after editing any of them (i.e. run `dotnet build` before `dotnet run --no-build`, or just drop `--no-build`). + +### 1. `SupportedCommand.cs` — the source of truth for *which* commands exist + +`AllSupportedCommands` lists every command (and sub‑command) Garnet supports, mapping the wire name to its `RespCommand` enum value and `StoreType`. + +```csharp +// Parent command with sub-commands and no keys (StoreType.None): +new("ACL", RespCommand.ACL, StoreType.None, +[ + new("ACL|CAT", RespCommand.ACL_CAT), + new("ACL|SETUSER", RespCommand.ACL_SETUSER), + // ... +]), + +// Simple key-value command in the main (string) store: +new("APPEND", RespCommand.APPEND, StoreType.Main), +``` + +`StoreType`: `Main` (string store), `Object` (object store), `All` (both), `None` (no keys / admin). + +- **Add a command** → add an entry here. If it also exists on the baseline server, its info/docs are fetched from the server; if it is Garnet‑only, add it to the override files (below). +- **Remove a command** → delete its entry here; the tool will drop it from the resource files. + +### 2. `GarnetCommandsInfo.json` — info for Garnet‑only commands (and info overrides) + +Add an entry for any command that is **not** returned by the baseline RESP server (e.g. `DELIFGREATER`, `SETIFMATCH`, `RI.*`), or to override the info of a standard command. + +```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" +} +``` + +### 3. `GarnetCommandsDocs.json` — docs for Garnet‑only commands (and per‑subcommand overrides) + +Add an entry for Garnet‑only command docs, **or override individual summaries** of a standard command (see "Overriding individual summaries" below). + +```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 } + ] +} +``` + +`Group` must be a valid `RespCommandGroup` value (`Bitmap`, `Cluster`, `Connection`, `Generic`, `Geo`, `Hash`, `HyperLogLog`, `List`, `Module`, `PubSub`, `Scripting`, `Sentinel`, `Server`, `Set`, `SortedSet`, `Stream`, `String`, `Transactions`). Do **not** invent group names. + +--- + +## How it works + +`Program.cs` runs two stages in sequence: **info first, then docs**. Each stage: + +1. **Loads the current ("existing") resource** — `RespCommandsInfo.json` / `RespCommandsDocs.json` read from the compiled **`Garnet.resources`** assembly (not the files on disk). This is the baseline it diffs against. +2. **Computes the diff** (`CommonUtils.GetCommandsToAddAndRemove`) between `SupportedCommand.cs` and the existing resource: + - in `SupportedCommand.cs` but not in existing → **to add** + - in existing but not in `SupportedCommand.cs` (and not `--ignore`d) → **to remove** +3. **Prompts for confirmation** (`Would you like to continue? (Y/N)`), printing exactly what will be added/removed. If there is nothing to do it logs `No commands to update` and returns. +4. **Queries the baseline server** for the metadata of the *to‑add* standard commands (`COMMAND INFO` / `COMMAND DOCS`). +5. **Merges** server results with the Garnet override file: + - **Parent/base command:** the Garnet override wins when a command is present in both. + - **Sub‑commands:** merged **by name** — Garnet override sub‑commands are added first, the server fills in only the ones the override did not specify (`Add` then `TryAdd`). +6. **Writes** the resource file to `--output` (unchanged commands are preserved verbatim from the existing resource). + +> The **docs stage only runs if the info stage returns success.** If the info stage has nothing to update (or you answer `N`), the docs stage is skipped. To regenerate docs, the info stage must also have at least one add/remove — see "Regenerating one command surgically". + +### Overriding individual summaries (while still using the tool) + +Because the merge is **per‑subcommand and override‑wins**, you can override a single summary without hand‑editing the generated file: + +1. Set the desired `Summary` for the sub‑command in **`GarnetCommandsDocs.json`** (list the parent command with the sub‑commands you want to control; sub‑commands you omit come from the server). +2. Rebuild the tool and run it. The generated `RespCommandsDocs.json` picks up your summary; everything else comes from the server. + +Example (from the `OBJECT` command): `GarnetCommandsDocs.json` lists `OBJECT` with sub‑commands `OBJECT|ENCODING`, `OBJECT|FREQ`, `OBJECT|IDLETIME`, `OBJECT|REFCOUNT`, `OBJECT|HELP`, each carrying a Garnet‑accurate `Summary`. The tool copies those verbatim and takes nothing from the server for them. + +--- + +## Command‑line options (`Options.cs`) + +| Option | Alias | Default | Description | +|--------|-------|---------|-------------| +| `--port` | `-p` | `6379` | Baseline RESP server port (must match the published Docker host port). | +| `--host` | `-h` | `127.0.0.1` | Baseline RESP server host. | +| `--output` | `-o` | *(cwd)* | Directory to write the JSON files. Use `../../libs/resources` from the tool folder. | +| `--force` | `-f` | `false` | Ignore the existing resource and regenerate **every** command from server + overrides. | +| `--ignore` | `-i` | *(none)* | Comma‑separated commands to leave untouched (neither add nor remove). | +| `--yes` | `-y` | `false` | Auto-confirm all prompts (non-interactive / scripted runs). | + +### Why `--ignore`? + +A command that is in the current resource files but **not** in `SupportedCommand.cs` would otherwise be flagged for **removal**. `--ignore` tells the tool to leave such commands untouched (neither add nor remove). + +Every command Garnet ships is registered in `SupportedCommand.cs` (with its metadata in the override files where it isn't queryable from the baseline server), so a normal regeneration needs **no `--ignore` flag**. Reach for it only as a temporary escape hatch — e.g. if you are mid‑way through adding a command and its resource entry exists before you have finished wiring up `SupportedCommand.cs`, or to protect an entry you are intentionally hand‑maintaining outside the tool. + +`--ignore` is command‑level only (there is no CLI flag to ignore a single sub‑command). + +--- + +## After running + +```bash +# Rebuild Garnet so the server embeds the new resources +dotnet build Garnet.slnx -c Debug -f net10.0 + +# Sanity-check the resource consistency tests +dotnet test test/standalone/Garnet.test -f net10.0 -c Debug \ + --filter "FullyQualifiedName~RespCommandTests.CommandsInfoCoverageTest|FullyQualifiedName~RespCommandTests.CommandsDocsCoverageTest" + +# Review the diff — it should contain only your intended change +git diff libs/resources/ +``` + +--- + +## Caveats & troubleshooting + +- **Interactive prompt (and how to skip it).** By default the tool prompts `Would you like to continue? (Y/N)` via `Console.ReadKey()`, which does **not** read piped input (`echo Y | dotnet run …` does not work). For non-interactive / scripted runs, pass **`-y` / `--yes`** to auto-confirm both prompts: + + ```bash + dotnet run -f net10.0 --no-build -- --port 6399 --output ../../libs/resources --yes + ``` + +- **Rebuild after editing inputs.** `SupportedCommand.cs`, `GarnetCommandsInfo.json`, and `GarnetCommandsDocs.json` are compiled/embedded into the tool assembly. If you use `--no-build`, run `dotnet build` first, otherwise your edits are not picked up. +- **`--force` re‑queries everything.** It regenerates all ~250 commands from the baseline server, so the output reflects *that server's* exact metadata — e.g. a Valkey baseline yields Valkey wording ("primary" vs "master"), Valkey‑specific flags, and its sub‑command ordering (an internal sub‑command such as `CLUSTER|RESERVE` may move). Use it to rebuild from scratch or to intentionally re‑baseline against a server; for a focused change, prefer a targeted non‑force run. +- **Docs stage skipped ("No commands to update").** The docs stage only runs after the info stage succeeds with at least one change. See below. +- **Unrecognized flags/ACL categories are skipped with a warning.** If the baseline server reports a command flag or ACL category Garnet does not model (e.g. a newer server renamed one), the info parser logs a warning and skips it rather than aborting. Review such warnings — a genuine rename should be added as a `[Description]`/`[EnumDescriptionAlias]` on the relevant enum. +- **Do not hand‑edit the generated files.** They carry a UTF‑8 BOM, use relaxed JSON escaping (literal `'`, `` ` ``, `+` rather than `\uXXXX`), and have no trailing newline. The tool writes them consistently; hand‑edits will drift. + +### Regenerating one command surgically + +To re‑emit **only one command** (e.g. after changing just its override summary) without touching any other entry and without a full `--force`: + +1. Temporarily remove that command's entry from `libs/resources/RespCommandsInfo.json` **and** `libs/resources/RespCommandsDocs.json`. +2. Rebuild the resources so the embedded baseline reflects the removal: + `dotnet build playground/CommandInfoUpdater/CommandInfoUpdater.csproj -f net10.0` +3. Run the tool non‑force. The command is now "to add" in **both** stages (so the info stage has a change and the docs stage runs), and only that command is re‑queried/re‑merged; every other entry is preserved verbatim. +4. `git diff libs/resources/` — the diff is limited to the single command. + +--- + +## Related + +- Adding a new command end‑to‑end: `.github/skills/add-garnet-command/SKILL.md` (Step 8 covers this tool). +- Developer docs: diff --git a/playground/CommandInfoUpdater/RespCommandDocsParser.cs b/playground/CommandInfoUpdater/RespCommandDocsParser.cs index 391929819e9..aff3f1dcd50 100644 --- a/playground/CommandInfoUpdater/RespCommandDocsParser.cs +++ b/playground/CommandInfoUpdater/RespCommandDocsParser.cs @@ -34,7 +34,7 @@ public static bool TryReadFromResp(RedisResult result, int resultStartIdx, IRead if (result.Length - resultStartIdx < 2) return false; if (result[resultStartIdx].Resp3Type != ResultType.BulkString) return false; - cmdName = result[resultStartIdx].ToString().ToUpper(); + cmdName = result[resultStartIdx].ToString().ToUpperInvariant(); if (result[resultStartIdx + 1].Resp3Type != ResultType.Array) return false; var elemCount = result[resultStartIdx + 1].Length; @@ -148,7 +148,7 @@ internal static bool TryReadFromResp(RedisResult result, out RespCommandArgument if (string.Equals(key, "name")) { if (elemVal.Resp3Type != ResultType.BulkString) return false; - name = elemVal.ToString().ToUpper(); + name = elemVal.ToString().ToUpperInvariant(); } else if (string.Equals(key, "display_text")) { diff --git a/playground/CommandInfoUpdater/RespCommandInfoParser.cs b/playground/CommandInfoUpdater/RespCommandInfoParser.cs index c34a4469f89..8a2baa67733 100644 --- a/playground/CommandInfoUpdater/RespCommandInfoParser.cs +++ b/playground/CommandInfoUpdater/RespCommandInfoParser.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using Garnet.common; +using Microsoft.Extensions.Logging; namespace Garnet.server { @@ -18,8 +19,9 @@ public class RespCommandInfoParser /// Mapping between command name, Garnet RespCommand and StoreType /// Parsed RespCommandsInfo object /// Name of parent command, null if none + /// Logger /// True if parsing successful - public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDictionary supportedCommands, out RespCommandsInfo commandInfo, string parentCommand = null) + public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDictionary supportedCommands, out RespCommandsInfo commandInfo, string parentCommand = null, ILogger logger = null) { commandInfo = default; @@ -46,9 +48,14 @@ public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDic if (!RespReadUtils.TryReadUnsignedArrayLength(out var flagCount, ref ptr, end)) return false; for (var flagIdx = 0; flagIdx < flagCount; flagIdx++) { - if (!RespReadUtils.TryReadSimpleString(out var strFlag, ref ptr, end) - || !EnumUtils.TryParseEnumFromDescription(strFlag, out var flag)) - return false; + if (!RespReadUtils.TryReadSimpleString(out var strFlag, ref ptr, end)) return false; + if (!EnumUtils.TryParseEnumFromDescription(strFlag, out var flag)) + { + // Skip flags Garnet does not model (e.g. a flag added or renamed by a newer server + // version) rather than aborting the whole run; surface it so it can be reviewed. + logger?.LogWarning("Skipping unrecognized command flag '{flag}' for command '{command}'.", strFlag, name); + continue; + } flags |= flag; } @@ -69,9 +76,13 @@ public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDic if (!RespReadUtils.TryReadUnsignedArrayLength(out var aclCatCount, ref ptr, end)) return false; for (var aclCatIdx = 0; aclCatIdx < aclCatCount; aclCatIdx++) { - if (!RespReadUtils.TryReadSimpleString(out var strAclCat, ref ptr, end) - || !EnumUtils.TryParseEnumFromDescription(strAclCat.TrimStart('@'), out var aclCat)) - return false; + if (!RespReadUtils.TryReadSimpleString(out var strAclCat, ref ptr, end)) return false; + if (!EnumUtils.TryParseEnumFromDescription(strAclCat.TrimStart('@'), out var aclCat)) + { + // Skip ACL categories Garnet does not model rather than aborting; surface it for review. + logger?.LogWarning("Skipping unrecognized ACL category '{aclCategory}' for command '{command}'.", strAclCat, name); + continue; + } aclCategories |= aclCat; } @@ -92,7 +103,7 @@ public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDic var subCommands = new List(); for (var scIdx = 0; scIdx < scCount; scIdx++) { - if (!TryReadFromResp(ref ptr, end, supportedCommands, out commandInfo, name)) + if (!TryReadFromResp(ref ptr, end, supportedCommands, out commandInfo, name, logger)) return false; subCommands.Add(commandInfo); @@ -102,7 +113,7 @@ public static unsafe bool TryReadFromResp(ref byte* ptr, byte* end, IReadOnlyDic commandInfo = new RespCommandsInfo() { Command = supportedCommand.Item1, - Name = name.ToUpper(), + Name = name.ToUpperInvariant(), IsInternal = false, Arity = arity, Flags = flags, diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index 0560310ba17..73947001cad 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -93,6 +93,7 @@ public class SupportedCommand new("CLUSTER|REPLICAS", RespCommand.CLUSTER_REPLICAS), new("CLUSTER|REPLICATE", RespCommand.CLUSTER_REPLICATE), new("CLUSTER|RESET", RespCommand.CLUSTER_RESET), + new("CLUSTER|RESERVE", RespCommand.CLUSTER_RESERVE), new("CLUSTER|SEND_CKPT_FILE_SEGMENT", RespCommand.CLUSTER_SEND_CKPT_FILE_SEGMENT), new("CLUSTER|SEND_CKPT_METADATA", RespCommand.CLUSTER_SEND_CKPT_METADATA), new("CLUSTER|SNAPSHOT_DATA", RespCommand.CLUSTER_SNAPSHOT_DATA), @@ -225,6 +226,14 @@ public class SupportedCommand new("MODULE|LOADCS", RespCommand.MODULE_LOADCS), ]), new("MONITOR", RespCommand.MONITOR), + new("OBJECT", RespCommand.OBJECT, StoreType.None, + [ + new("OBJECT|ENCODING", RespCommand.OBJECT_ENCODING), + new("OBJECT|FREQ", RespCommand.OBJECT_FREQ), + new("OBJECT|HELP", RespCommand.OBJECT_HELP), + new("OBJECT|IDLETIME", RespCommand.OBJECT_IDLETIME), + new("OBJECT|REFCOUNT", RespCommand.OBJECT_REFCOUNT), + ]), new("MSET", RespCommand.MSET, StoreType.Main), new("MSETNX", RespCommand.MSETNX, StoreType.Main), new("MULTI", RespCommand.MULTI), @@ -364,6 +373,18 @@ public class SupportedCommand new("ZCOLLECT", RespCommand.ZCOLLECT, StoreType.Object), new("ZUNION", RespCommand.ZUNION, StoreType.Object), new("ZUNIONSTORE", RespCommand.ZUNIONSTORE, StoreType.Object), + new("VADD", RespCommand.VADD), + new("VCARD", RespCommand.VCARD), + new("VDIM", RespCommand.VDIM), + new("VEMB", RespCommand.VEMB), + new("VGETATTR", RespCommand.VGETATTR), + new("VINFO", RespCommand.VINFO), + new("VISMEMBER", RespCommand.VISMEMBER), + new("VLINKS", RespCommand.VLINKS), + new("VRANDMEMBER", RespCommand.VRANDMEMBER), + new("VREM", RespCommand.VREM), + new("VSETATTR", RespCommand.VSETATTR), + new("VSIM", RespCommand.VSIM), new("EVAL", RespCommand.EVAL), new("EVALSHA", RespCommand.EVALSHA), new("SCRIPT", RespCommand.SCRIPT, StoreType.None, diff --git a/test/standalone/Garnet.test.acl/Resp/ACL/RespCommandTests.cs b/test/standalone/Garnet.test.acl/Resp/ACL/RespCommandTests.cs index de150bc1d13..d3eb4c19f58 100644 --- a/test/standalone/Garnet.test.acl/Resp/ACL/RespCommandTests.cs +++ b/test/standalone/Garnet.test.acl/Resp/ACL/RespCommandTests.cs @@ -90,7 +90,7 @@ public void AllCommandsCovered() ClassicAssert.IsTrue(RespCommandsInfo.TryGetRespCommandNames(out IReadOnlySet advertisedCommands), "Couldn't get advertised RESP commands"); // TODO: See if these commands could be identified programmatically - IEnumerable withOnlySubCommands = ["ACL", "CLIENT", "CLUSTER", "CONFIG", "LATENCY", "MEMORY", "MODULE", "PUBSUB", "SCRIPT", "SLOWLOG"]; + IEnumerable withOnlySubCommands = ["ACL", "CLIENT", "CLUSTER", "CONFIG", "LATENCY", "MEMORY", "MODULE", "OBJECT", "PUBSUB", "SCRIPT", "SLOWLOG"]; IEnumerable notCoveredByACLs = allInfo.Where(static x => x.Value.Flags.HasFlag(RespCommandFlags.NoAuth)).Select(static kv => kv.Key); // Check tests against RespCommandsInfo @@ -4812,6 +4812,82 @@ static async Task DoMemoryUsageSamplesAsync(GarnetClient client) } } + [Test] + public async Task ObjectEncodingACLsAsync() + { + await CheckCommandsAsync( + "OBJECT ENCODING", + [DoObjectEncodingAsync] + ).ConfigureAwait(false); + + static async Task DoObjectEncodingAsync(GarnetClient client) + { + string val = await client.ExecuteForStringResultAsync("OBJECT", ["ENCODING", "foo"]).ConfigureAwait(false); + ClassicAssert.IsNull(val); + } + } + + [Test] + public async Task ObjectFreqACLsAsync() + { + await CheckCommandsAsync( + "OBJECT FREQ", + [DoObjectFreqAsync] + ).ConfigureAwait(false); + + static async Task DoObjectFreqAsync(GarnetClient client) + { + string val = await client.ExecuteForStringResultAsync("OBJECT", ["FREQ", "foo"]).ConfigureAwait(false); + ClassicAssert.IsNull(val); + } + } + + [Test] + public async Task ObjectIdletimeACLsAsync() + { + await CheckCommandsAsync( + "OBJECT IDLETIME", + [DoObjectIdletimeAsync] + ).ConfigureAwait(false); + + static async Task DoObjectIdletimeAsync(GarnetClient client) + { + string val = await client.ExecuteForStringResultAsync("OBJECT", ["IDLETIME", "foo"]).ConfigureAwait(false); + ClassicAssert.IsNull(val); + } + } + + [Test] + public async Task ObjectRefcountACLsAsync() + { + await CheckCommandsAsync( + "OBJECT REFCOUNT", + [DoObjectRefcountAsync] + ).ConfigureAwait(false); + + static async Task DoObjectRefcountAsync(GarnetClient client) + { + string val = await client.ExecuteForStringResultAsync("OBJECT", ["REFCOUNT", "foo"]).ConfigureAwait(false); + ClassicAssert.IsNull(val); + } + } + + [Test] + public async Task ObjectHelpACLsAsync() + { + await CheckCommandsAsync( + "OBJECT HELP", + [DoObjectHelpAsync] + ).ConfigureAwait(false); + + static async Task DoObjectHelpAsync(GarnetClient client) + { + string[] val = await client.ExecuteForStringArrayResultAsync("OBJECT", ["HELP"]).ConfigureAwait(false); + ClassicAssert.IsNotNull(val); + Assert.That(val.Length, Is.GreaterThan(0)); + } + } + [Test] public async Task MGetACLsAsync() { diff --git a/test/standalone/Garnet.test/RespCommandTests.cs b/test/standalone/Garnet.test/RespCommandTests.cs index 6a633294a5f..9dc62aa0ee0 100644 --- a/test/standalone/Garnet.test/RespCommandTests.cs +++ b/test/standalone/Garnet.test/RespCommandTests.cs @@ -146,6 +146,27 @@ public void CommandsDocsCoverageTest() "Some commands have missing docs. Please see https://microsoft.github.io/garnet/docs/dev/garnet-api#adding-command-info for more details."); } + /// + /// Verify that a command flag with a wire-name alias parses from either name, but still + /// serializes to its canonical description. Valkey 8.x renamed the "skip_slowlog" flag to + /// "skip_commandlog"; the CommandInfoUpdater tool must accept either so it can query metadata + /// from either a Redis or a Valkey baseline server. + /// + [Test] + public void RespCommandFlagAliasParsesTest() + { + ClassicAssert.IsTrue(EnumUtils.TryParseEnumFromDescription("skip_slowlog", out var canonical)); + ClassicAssert.AreEqual(RespCommandFlags.SkipSlowLog, canonical); + + ClassicAssert.IsTrue(EnumUtils.TryParseEnumFromDescription("skip_commandlog", out var alias)); + ClassicAssert.AreEqual(RespCommandFlags.SkipSlowLog, alias); + + // The alias affects parsing only — serialization still uses the canonical description. + var descriptions = EnumUtils.GetEnumDescriptions(RespCommandFlags.SkipSlowLog); + ClassicAssert.AreEqual(1, descriptions.Length); + ClassicAssert.AreEqual("skip_slowlog", descriptions[0]); + } + /// /// Verify info in SimpleRespCommandInfo matches information in full RespCommandsInfo /// diff --git a/test/standalone/Garnet.test/RespObjectCommandTests.cs b/test/standalone/Garnet.test/RespObjectCommandTests.cs new file mode 100644 index 00000000000..82bc5369c41 --- /dev/null +++ b/test/standalone/Garnet.test/RespObjectCommandTests.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Linq; +using System.Text; +using NUnit.Framework; +using NUnit.Framework.Legacy; +using StackExchange.Redis; + +namespace Garnet.test +{ + /// + /// Tests for the OBJECT command (ENCODING / REFCOUNT / IDLETIME / FREQ / HELP). + /// OBJECT ENCODING reports Garnet's actual internal representation of each value. + /// + [TestFixture] + public class RespObjectCommandTests : 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(); + } + + private static string GetEncoding(IDatabase db, string key) + => db.Execute("OBJECT", ["ENCODING", key]).ToString(); + + [Test] + public void ObjectEncodingStringTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Garnet stores string values as raw bytes (inline or overflow) and has no native + // integer or embedded-string representation, so all strings report "raw". + db.StringSet("s:int", "12345"); + ClassicAssert.AreEqual("raw", GetEncoding(db, "s:int")); + + db.StringSet("s:short", "hello"); + ClassicAssert.AreEqual("raw", GetEncoding(db, "s:short")); + + db.StringSet("s:long", new string('a', 100)); + ClassicAssert.AreEqual("raw", GetEncoding(db, "s:long")); + + db.StringSet("s:empty", ""); + ClassicAssert.AreEqual("raw", GetEncoding(db, "s:empty")); + } + + [Test] + public void ObjectEncodingHashTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Garnet stores hashes as a hashtable regardless of size. + db.HashSet("h:small", [new HashEntry("f", "v")]); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "h:small")); + + var entries = Enumerable.Range(0, 600).Select(i => new HashEntry($"f{i}", $"v{i}")).ToArray(); + db.HashSet("h:many", entries); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "h:many")); + } + + [Test] + public void ObjectEncodingHashWithFieldExpirationTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Field TTLs do not change Garnet's internal representation. + db.HashSet("h:ttl", [new HashEntry("f", "v")]); + db.Execute("HEXPIRE", ["h:ttl", "1000", "FIELDS", "1", "f"]); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "h:ttl")); + } + + [Test] + public void ObjectEncodingListTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Garnet stores lists as a linked list, reported as "quicklist" regardless of size. + db.ListRightPush("l:small", [new RedisValue("a"), new RedisValue("b")]); + ClassicAssert.AreEqual("quicklist", GetEncoding(db, "l:small")); + + var values = Enumerable.Range(0, 300).Select(i => new RedisValue($"e{i}")).ToArray(); + db.ListRightPush("l:big", values); + ClassicAssert.AreEqual("quicklist", GetEncoding(db, "l:big")); + } + + [Test] + public void ObjectEncodingSetTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Garnet stores sets as a hashtable regardless of contents (no intset/listpack). + db.SetAdd("set:int", [new RedisValue("1"), new RedisValue("2"), new RedisValue("3")]); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "set:int")); + + db.SetAdd("set:str", [new RedisValue("a"), new RedisValue("b"), new RedisValue("c")]); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "set:str")); + + var members = Enumerable.Range(0, 200).Select(i => new RedisValue($"m{i}")).ToArray(); + db.SetAdd("set:many", members); + ClassicAssert.AreEqual("hashtable", GetEncoding(db, "set:many")); + } + + [Test] + public void ObjectEncodingSortedSetTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Garnet stores sorted sets as a skiplist regardless of size. + db.SortedSetAdd("z:small", [new SortedSetEntry("a", 1)]); + ClassicAssert.AreEqual("skiplist", GetEncoding(db, "z:small")); + + var entries = Enumerable.Range(0, 200).Select(i => new SortedSetEntry($"e{i}", i)).ToArray(); + db.SortedSetAdd("z:many", entries); + ClassicAssert.AreEqual("skiplist", GetEncoding(db, "z:many")); + } + + [Test] + public void ObjectEncodingMissingKeyReturnsNil() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var result = db.Execute("OBJECT", ["ENCODING", "nonexistent"]); + ClassicAssert.IsTrue(result.IsNull); + } + + [Test] + public void ObjectRefcountTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("k", "v"); + ClassicAssert.AreEqual(1, (long)db.Execute("OBJECT", ["REFCOUNT", "k"])); + + var missing = db.Execute("OBJECT", ["REFCOUNT", "nonexistent"]); + ClassicAssert.IsTrue(missing.IsNull); + } + + [Test] + public void ObjectIdletimeTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("k", "v"); + ClassicAssert.AreEqual(0, (long)db.Execute("OBJECT", ["IDLETIME", "k"])); + + var missing = db.Execute("OBJECT", ["IDLETIME", "nonexistent"]); + ClassicAssert.IsTrue(missing.IsNull); + } + + [Test] + public void ObjectFreqTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("k", "v"); + + // OBJECT FREQ is not supported (Garnet does not track access frequency), so it errors. + var ex = Assert.Throws(() => db.Execute("OBJECT", ["FREQ", "k"])); + StringAssert.Contains("not supported", ex.Message); + + // A missing key still returns nil. + var missing = db.Execute("OBJECT", ["FREQ", "nonexistent"]); + ClassicAssert.IsTrue(missing.IsNull); + } + + [Test] + public void ObjectHelpTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var result = (RedisResult[])db.Execute("OBJECT", ["HELP"]); + ClassicAssert.IsNotNull(result); + Assert.That(result.Length, Is.GreaterThan(0)); + StringAssert.Contains("OBJECT", result[0].ToString()); + } + + [Test] + public void ObjectErrorsTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Unknown subcommand. + var ex = Assert.Throws(() => db.Execute("OBJECT", ["FOO", "k"])); + StringAssert.Contains("unknown subcommand", ex.Message); + + // Missing key argument. + ex = Assert.Throws(() => db.Execute("OBJECT", ["ENCODING"])); + StringAssert.Contains("wrong number of arguments", ex.Message); + + // Bare OBJECT. + ex = Assert.Throws(() => db.Execute("OBJECT", [])); + StringAssert.Contains("wrong number of arguments", ex.Message); + } + + [Test] + public void ObjectEncodingRawProtocolTest() + { + using var lightClientRequest = TestUtils.CreateRequest(); + + lightClientRequest.SendCommand("SET s 12345"); + var response = lightClientRequest.SendCommand("OBJECT ENCODING s"); + var expected = "$3\r\nraw\r\n"; + ClassicAssert.AreEqual(expected, Encoding.ASCII.GetString(response, 0, expected.Length)); + + // Missing key -> nil. + response = lightClientRequest.SendCommand("OBJECT ENCODING missing"); + expected = "$-1\r\n"; + ClassicAssert.AreEqual(expected, Encoding.ASCII.GetString(response, 0, expected.Length)); + } + } +} \ No newline at end of file diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 40c3c223f6c..0ea88adb9bc 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -241,11 +241,11 @@ Note that this list is subject to change as we continue to expand our API comman | | LOAD | ➖ | | | | LOADEX | ➖ | | | | UNLOAD | ➖ | | -| **OBJECT** | ENCODING | ➖ | | -| | FREQ | ➖ | | -| | HELP | ➖ | | -| | IDLETIME | ➖ | | -| | REFCOUNT | ➖ | | +| **OBJECT** | ENCODING | ➕ | | +| | FREQ | ➕ | Always returns an error; Garnet does not track access frequency (LFU) | +| | HELP | ➕ | | +| | IDLETIME | ➕ | Always returns 0; Garnet does not track per-key idle time | +| | REFCOUNT | ➕ | Always returns 1; Garnet does not share value objects | | **PUB/SUB** | [PSUBSCRIBE](analytics.md#psubscribe) | ➕ | | | | [PUBLISH](analytics.md#publish) | ➕ | | | | [PUBSUB CHANNELS](analytics.md#pubsub-channels) | ➕ | |