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) | ➕ | |