diff --git a/Directory.Build.props b/Directory.Build.props
index 55c0088fe..3f88b7ba1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,7 +12,7 @@
NETSDK1069
- $(NoWarn);NU5105;NU1507;SER004;SER005;SER009
+ $(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER012
https://github.com/StackExchange/StackExchange.Redis/releases
https://seredis.dev/
MIT
diff --git a/docs/ClusterSlotStats.md b/docs/ClusterSlotStats.md
new file mode 100644
index 000000000..efff96110
--- /dev/null
+++ b/docs/ClusterSlotStats.md
@@ -0,0 +1,62 @@
+Cluster slot statistics
+===
+
+The `CLUSTER SLOT-STATS` command reports per-slot usage statistics - key counts, and optionally CPU,
+memory and network metrics - so you can see *where* in the keyspace a cluster's load lands, and which
+slots would be worth migrating. It is available in Redis 8.2 and later, on cluster-enabled servers
+only (a standalone server answers with an error).
+
+This command is available via the `IServer.ClusterSlotStats*` methods, which are
+[experimental (`SER012`)](exp/SER012) while the API shape settles:
+
+``` c#
+// Get a server instance; each node reports only the slots its own shard serves,
+// so ask each node to see the whole cluster.
+IConnectionMultiplexer muxer = ... // connect to a Redis 8.2+ cluster
+var server = muxer.GetServer(endpoint);
+
+// Form 1: the stats for a slot range (inclusive), in ascending slot order.
+var range = await server.ClusterSlotStatsBySlotRangeAsync(0, 16383);
+
+// Form 2: the node's slots sorted by a metric, busiest first; here, the ten
+// slots with the most keys.
+var top = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 10);
+
+foreach (var slot in top.Slots)
+{
+ Console.WriteLine($"slot {slot.Slot}: {slot.KeyCount} keys, cpu: {slot.CpuTime}");
+}
+```
+
+Each `ClusterSlotStatsEntry` carries:
+
+- `Slot`: the hash slot the entry describes.
+- `KeyCount`: the number of keys in the slot; always collected.
+- `CpuTime`: CPU time spent serving the slot's keys (the server's `cpu-usec`, as a `TimeSpan?`).
+- `MemoryBytes`: memory used by the slot's keys.
+- `NetworkBytesIn` / `NetworkBytesOut`: network traffic for the slot's keys.
+- `Metadata`: any metric the server reports that this library does not recognize; the metric set is
+ configuration-dependent and has grown before, so unknown metrics are preserved rather than discarded.
+
+Metric collection
+---
+
+Only `key-count` is always collected. The other metrics are gated by the `cluster-slot-stats-enabled`
+server configuration, which takes the metric groups to enable (`cpu`, `net`, `mem`, or `yes` for all;
+`mem` additionally requires memory tracking from server startup):
+
+```
+CONFIG SET cluster-slot-stats-enabled "cpu net"
+```
+
+A metric that is not being collected comes back as `null` on `ClusterSlotStatsEntry` - deliberately
+distinct from a collected zero. Sorting (`ClusterSlotStatsOrderBy`) by a metric that is not being
+collected is refused by the server; `ClusterSlotStatsMetric.KeyCount` always works.
+
+Relationship to `HOTKEYS`
+---
+
+The two are complementary: [`HOTKEYS`](HotKeys) profiles *which keys* are hot on a server, while
+`CLUSTER SLOT-STATS` reports *which slots* carry the load - the granularity at which a cluster can
+actually rebalance. A typical investigation finds the heavy slots with `CLUSTER SLOT-STATS ORDERBY`,
+then profiles the keys within them with `HOTKEYS START ... SLOTS ...`.
diff --git a/docs/CommandCoverage.md b/docs/CommandCoverage.md
index c055d71ff..f8ff94211 100644
--- a/docs/CommandCoverage.md
+++ b/docs/CommandCoverage.md
@@ -149,7 +149,6 @@ Operator tooling. All of it works through `Execute` on an `IServer`; none of it
| `CLIENT NO-TOUCH` | 7.2.0 | `deferred` | Per-connection flag, as `NO-EVICT`. |
| `CLUSTER SHARDS` | 7.0.0 | `deferred` | Not used anywhere today: `ClusterConfiguration` is built from `CLUSTER NODES`, and configuration export captures `NODES` and `SLOTS`. Moving topology discovery to `SHARDS`, or exposing it typed, is a design question. |
| `CLUSTER LINKS` | 7.0.0 | `deferred` | Topology introspection. |
-| `CLUSTER SLOT-STATS` | 8.2.0 | `deferred` | Per-slot statistics; pairs with hot-key tooling. |
## Todo: accepted gaps
diff --git a/docs/exp/SER012.md b/docs/exp/SER012.md
new file mode 100644
index 000000000..115a0eee8
--- /dev/null
+++ b/docs/exp/SER012.md
@@ -0,0 +1,25 @@
+The `IServer.ClusterSlotStats*` methods expose `CLUSTER SLOT-STATS` (Redis 8.2 and later); see
+[Cluster slot statistics](../ClusterSlotStats).
+
+The server command is stable, but this library binding is new and marked experimental while the API
+shape settles:
+
+- The metric set is configuration-dependent (`cluster-slot-stats-enabled`) and documented by the
+ server as extensible; the typed surface (`ClusterSlotStatsMetric`, the properties on
+ `ClusterSlotStatsEntry`) may grow or change as metrics are added.
+- How the per-node results are best aggregated across a cluster (today: ask each node yourself) is an
+ open design question that may reshape the entry points.
+
+Until the marker is removed, the API may change between any two versions without breaking-change
+ceremony. If you acknowledge this, you can suppress the warning by adding the following to your
+`csproj` file:
+
+```xml
+$(NoWarn);SER012
+```
+
+or more granularly / locally in C#:
+
+``` c#
+#pragma warning disable SER012
+```
diff --git a/docs/index.md b/docs/index.md
index a65e482b9..64cf22ac5 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -51,6 +51,7 @@ Documentation
- [Vector Sets](VectorSets) - how to use Vector Sets for similarity search with embeddings
- [Hash Import](HImport) - bulk-importing many hashes that share a common set of field names
- [Hash Tags and Slots](HashTags) - co-locating keys in the same cluster slot for multi-key operations
+- [Cluster slot statistics](ClusterSlotStats) - per-slot usage statistics via `CLUSTER SLOT-STATS`
- [Where are `KEYS` / `SCAN` / `FLUSH*`?](KeysScan) - how to use server-based commands
- [Profiling](Profiling) - profiling interfaces, as well as how to profile in an `async` world
- [Scripting](Scripting) - running Lua scripts, including the low-allocation `ScriptEvalLease` API
diff --git a/notes/cluster-slot-stats/plan.md b/notes/cluster-slot-stats/plan.md
new file mode 100644
index 000000000..a9c65ab3f
--- /dev/null
+++ b/notes/cluster-slot-stats/plan.md
@@ -0,0 +1,181 @@
+---
+feature: cluster-slot-stats
+decision_class: C
+estimated_size: small-medium
+tracks: redis/redis CLUSTER SLOT-STATS (src/commands/cluster-slot-stats.json, since 8.2.0; implementation src/cluster_slot_stats.c)
+target_version: "8.2"
+unit_targets:
+ - RoundTripUnitTests.ClusterSlotStatsRoundTrip
+ - ClusterSlotStatsTests (live, cluster topology)
+open_questions: 4
+---
+
+# CLUSTER SLOT-STATS: implementation plan
+
+No shared HLD exists for this command (it predates the parity pipeline); the facts below are read
+directly from the server: `src/commands/cluster-slot-stats.json` and `src/cluster_slot_stats.c`
+(`clusterSlotStatsCommand`, `addReplySlotStat`), verified against a live 8.10 cluster
+(`redislabs/client-libs-test`, the repo test topology). `docs/CommandCoverage.md` lists the command
+as `deferred` ("pairs with hot-key tooling"); the hot-key tooling (`HOTKEYS`, `IServer.HotKeys*`)
+has since shipped, so this promotes the row.
+
+## Server facts (all observed)
+
+- `CLUSTER SLOT-STATS `, arity -4,
+ flags STALE LOADING. Strictly a oneof: exactly one filter form per call.
+- **Cluster-only**: a non-cluster server answers `ERR This instance has cluster support disabled`
+ (observed on the topology's standalone node).
+- Only slots assigned to the answering node's **shard** are reported; `SLOTSRANGE` returns them in
+ ascending slot order (observed: a range owned by another shard returns an empty array), `ORDERBY`
+ sorted by the metric (ties: ascending slot), default `DESC`, `LIMIT` default = all (1..16384;
+ out of range → error).
+- Reply: array of `[slot, map]` pairs; the map always carries `key-count`, plus `memory-bytes`,
+ `cpu-usec`, `network-bytes-in`/`network-bytes-out` according to the `cluster-slot-stats-enabled`
+ config (multi-arg enum: `no`/`yes`/`cpu`/`net`/`mem`). Observed: `CONFIG SET
+ cluster-slot-stats-enabled "cpu net"` works at runtime; `yes` (which includes `mem`) is refused
+ at runtime when memory tracking wasn't enabled at boot. A map under RESP3, flat pairs under
+ RESP2 (`addReplyMapLen`).
+- `ORDERBY` by a metric whose collection is disabled → `ERR Unrecognized sort metric for ORDERBY.`
+- Metric wire names: `key-count`, `cpu-usec`, `memory-bytes`, `network-bytes-in`,
+ `network-bytes-out`.
+
+## Traced siblings
+
+- **`CLUSTER SLOTS`** (`ClusterSlots.cs`, `ClusterSlots.ResultProcessor.cs`,
+ `RedisServer.GetClusterSlotsMessage`, `ClusterSlotsTests.cs`) — the verified model for a parsed
+ `CLUSTER` subcommand on `IServer`: partial-interface file named for the feature, message built
+ with `Message.Create(-1, flags.WithCategory(NodeLocalRead), RedisCommand.CLUSTER, ...)`,
+ lenient aggregate parsing (drop a malformed entry, not the reply), `null` result for a nil reply.
+ No `RedisCommand` enum change (CLUSTER exists), no keyspace-isolation changes (server-scoped).
+- **`HOTKEYS`** (`HotKeys.cs`, `HotKeysField.cs`, `HotKeys.ResultProcessor.cs`,
+ `HotKeys.Server.cs`) — the model for the *reply-field* handling: an internal `[AsciiHash]` enum
+ (`HotKeysField`) with a generated `...Metadata.TryParse`, nominal not positional parsing, unknown
+ fields skipped/preserved, `cpu`-style counters exposed as `TimeSpan` via microseconds, and the
+ "profiling data on `IServer`" API shape this command pairs with operationally.
+
+## Requirements
+
+| # | Requirement | Where covered |
+|---|---|---|
+| R.1 | `SLOTSRANGE start end` form | `ClusterSlotStatsBySlotRange(Async)` |
+| R.2 | `ORDERBY metric [LIMIT n] [ASC\|DESC]` form | `ClusterSlotStatsOrderBy(Async)` |
+| R.3 | Reply: array of [slot, metric-map], RESP2 flat pairs / RESP3 map | `ClusterSlotStatsResult.Processor` (category tests, never `Prefix` comparisons) |
+| R.4 | `key-count` always; other metrics config-dependent | nullable properties on `ClusterSlotStatsEntry`; absent → `null` |
+| R.5 | Metrics the library doesn't know (future servers) | preserved in `ClusterSlotStatsEntry.Metadata`, as `ClusterSlotNode.Metadata` does |
+| R.6 | Cluster-only; shard-local answers | XML docs; live test asserts the standalone error and per-node ownership |
+| R.7 | Node-local read, safe to replay against that node | `flags.WithCategory(NodeLocalRead)`, as `GetClusterSlotsMessage` |
+| R.8 | Version gating | `RedisFeatures.ClusterSlotStats` => `v8_2_0_rc1` (8.2 RC1 = 8.1.240, the existing constant) |
+| NF.1 | New public surface is experimental | `[Experimental(Experiments.ClusterSlotStats)]`, new id `SER012` (SER011 is taken by the open set-member-expiry PR #3340) |
+
+## Public API (all `[Experimental("SER012")]`, on `IServer`)
+
+```csharp
+ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None);
+Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None);
+ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None);
+Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None);
+
+public enum ClusterSlotStatsMetric { KeyCount, CpuTime, MemoryBytes, NetworkBytesIn, NetworkBytesOut }
+
+public sealed class ClusterSlotStatsResult
+{
+ public IReadOnlyList Slots { get; }
+}
+
+public sealed class ClusterSlotStatsEntry
+{
+ public int Slot { get; }
+ public long KeyCount { get; }
+ public TimeSpan? CpuTime { get; } // cpu-usec; null when not collected
+ public long? MemoryBytes { get; } // null when not collected
+ public long? NetworkBytesIn { get; } // null when not collected
+ public long? NetworkBytesOut { get; } // null when not collected
+ public IReadOnlyList> Metadata { get; } // unrecognized metrics, preserved
+}
+```
+
+Grounding: two distinct method names (not overloads) because the grammar is a strict oneof and the
+optional-parameter shapes would otherwise trip RS0026; `Order` is the existing enum (`SORT`,
+sorted-set ranges); `limit = 0` means "don't send `LIMIT`" exactly as `HotKeysStart(count = 0)`
+means "server default"; `CpuTime` as `TimeSpan` from microseconds mirrors
+`HotKeysResult.TotalCpuTime`; the result wrapper + entry list mirrors
+`ClusterSlotsResult.Assignments`; `Metadata` preservation mirrors `ClusterSlotNode.Metadata`.
+Client-side validation: slots in `[SlotRange.MinSlot, SlotRange.MaxSlot]`, `startSlot <= endSlot`,
+`limit >= 0` (`ArgumentOutOfRangeException`), mirroring the server's own refusals.
+
+On the wire, `ASC` is sent only when `Order.Ascending` is requested and `LIMIT` only when
+`limit > 0`: the server defaults are descending/unlimited, and the repo convention (`Expiration`,
+`GetHashFieldExpireMessage`) is to send the minimal form.
+
+## Files
+
+| File | Change |
+|---|---|
+| `src/StackExchange.Redis/RedisLiterals.cs` | add `ORDERBY`, `SLOT_STATS` ("SLOT-STATS"), `SLOTSRANGE` (ASC/DESC/LIMIT exist) |
+| `src/StackExchange.Redis/ClusterSlotStats.cs` | add: `IServer` members, `ClusterSlotStatsMetric`, `ClusterSlotStatsResult`, `ClusterSlotStatsEntry` |
+| `src/StackExchange.Redis/ClusterSlotStatsField.cs` | add: internal `[AsciiHash]` field enum (as `HotKeysField`) |
+| `src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs` | add: processor (as `ClusterSlots.ResultProcessor.cs`) |
+| `src/StackExchange.Redis/ClusterSlotStats.Server.cs` | add: `RedisServer` implementations + internal static message builders (as `HotKeys.Server.cs` / `GetClusterSlotsMessage`) |
+| `src/StackExchange.Redis/RedisFeatures.cs` | add `ClusterSlotStats` => `v8_2_0_rc1` |
+| `src/RESPite/Shared/Experiments.cs` | add `ClusterSlotStats = "SER012"` |
+| `Directory.Build.props` (root) | NoWarn += `SER012` |
+| `docs/exp/SER012.md` | add (experiment page) |
+| `docs/ClusterSlotStats.md` + `docs/index.md` | add usage page + index row |
+| `src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt` | `[SER012]`-prefixed entries + bare `RedisFeatures.ClusterSlotStats.get`; sorted by `eng/public-api.py` (RS0016 does not fire in this environment, so the entries are hand-written against the shipped `[SER009]`/branch `[SER011]` precedents) |
+| `docs/CommandCoverage.md` | remove the `CLUSTER SLOT-STATS` `deferred` row |
+| `tests/.../RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs` | add: exact-bytes + parse tests, RESP2/RESP3 |
+| `tests/.../ClusterSlotStatsTests.cs` | add: live cluster tests |
+
+Not applicable: `Enums/RedisCommand.cs` (CLUSTER exists), keyspace isolation (server-scoped, as
+HOTKEYS), transaction analyzer (read-only reporting command; no pre-8.2 MULTI composition exists).
+
+## Ordered steps
+
+1. Literals; field enum; API types file; processor; server file. Build → collect RS0016 strings
+ into `PublicAPI.Unshipped.txt`.
+2. Features flag, experiment id, NoWarn, docs pages, coverage row.
+3. Round-trip tests (message bytes both forms incl. LIMIT/ASC minimality; replies: full map,
+ key-count-only map, unknown-metric preservation, empty array, nil → null) under RESP2 and RESP3.
+4. Live tests against the repo cluster topology (8.9.241 reports as 8.10; ≥ 8.2 requirement met):
+ ownership (a routed write's slot is reported by exactly the owning node with `key-count ≥ 1`),
+ ORDERBY limit/order, config-gated metrics appearing after `CONFIG SET cluster-slot-stats-enabled
+ "cpu net"` (restored in `finally`), disabled-metric ORDERBY error, standalone-server error.
+5. Full build `/p:CI=true`; run the new tests; update this plan's test section with results.
+
+## Older servers
+
+Pre-8.2 answers `ERR Unknown subcommand`; the feature flag lets callers probe. No behavioral
+change for any existing API.
+
+## Open questions
+
+1. **SER012 vs joining an existing experiment** — SER011 is "Server 8.12" on the open #3340 and
+ this is an 8.2 command; a feature-specific id (as SER007/SER010 were) avoids coupling the two
+ PRs. Default: `SER012 = ClusterSlotStats`.
+2. **`limit = 0` sentinel vs `long? limit = null`** — `HotKeysStart(count = 0)` is the precedent
+ next door, so 0-means-default wins; `null` would be the greenfield choice.
+3. **`CpuTime` naming** — the wire says `cpu-usec`; `HotKeysResult` exposes CPU microseconds as
+ `TimeSpan` properties named `*CpuTime*`, so the entry property and the metric enum member follow
+ it rather than the wire name.
+4. **8.12 cluster-topology note (out of scope here)** — 8.12-rc2 introduces
+ `cluster-bus-port-protected-mode` (default on), which refuses to start the repo's cluster nodes;
+ the fix (`cluster-bus-port-protected-mode no` in `tests/RedisConfigs/Cluster/*.conf`) can only
+ land together with an image bump, because servers ≤ 8.10 abort on the unknown directive. This
+ PR stays on the main topology image; the open 8.12 PRs that bump the image need the conf change.
+
+## Test plan
+
+- Round-trip: exact request bytes for `SLOTSRANGE 100 200`, `ORDERBY key-count`, `ORDERBY cpu-usec
+ LIMIT 5`, `ORDERBY network-bytes-out LIMIT 3 ASC`; replies parsed identically from RESP2 flat
+ pairs and RESP3 maps; unknown metric lands in `Metadata`; nil → null result; empty array → empty
+ `Slots`.
+- Live (cluster topology, `TestConfig.Current.ClusterServersAndPorts`): see step 4.
+- Results (2026-11-07, local): round-trip 10/10 pass (`ClusterSlotStatsRoundTrip`, includes the
+ argument-validation facts). Live 12/12 pass, zero skips (`ClusterSlotStatsTests`, 6 tests ×
+ RESP2/RESP3 against the repo cluster topology, server 8.9.241): the routed write was counted in
+ its slot, whole-range answers stayed shard-local and ascending, ORDERBY honoured LIMIT and both
+ orders, `CONFIG SET cluster-slot-stats-enabled "cpu net"` surfaced `CpuTime`/`NetworkBytes*`
+ (with `MemoryBytes` still null, and the config restored), ORDERBY on the disabled `mem` metric was
+ refused with the ORDERBY error, and the standalone node refused with "cluster support disabled".
+ Full solution build clean (0 warnings, 0 errors) across net461/net472/netstandard2.0/net8.0/net10.0;
+ `eng/public-api.py --check` passes.
diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs
index 93fd067f8..c8aa8f202 100644
--- a/src/RESPite/Shared/Experiments.cs
+++ b/src/RESPite/Shared/Experiments.cs
@@ -23,6 +23,7 @@ internal static class Experiments
public const string Respite = "SER004";
public const string UnitTesting = "SER005";
public const string Transport = "SER009";
+ public const string ClusterSlotStats = "SER012"; // SER011 is Server_8_12, on the open set-member-expiry PR
// ReSharper restore InconsistentNaming
diff --git a/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs b/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs
new file mode 100644
index 000000000..bbc7ede15
--- /dev/null
+++ b/src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs
@@ -0,0 +1,115 @@
+using System.Collections.Generic;
+using RESPite;
+using RESPite.Messages;
+
+namespace StackExchange.Redis;
+
+public sealed partial class ClusterSlotStatsResult
+{
+ internal static readonly ResultProcessor Processor = new ClusterSlotStatsResultProcessor();
+
+ private sealed class ClusterSlotStatsResultProcessor : ResultProcessor
+ {
+ protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader)
+ {
+ if (reader.IsNull)
+ {
+ SetResult(message, null);
+ return true;
+ }
+ if (!reader.IsAggregate) return false;
+
+ // the reply is an array of [slot, metric-map]; parse leniently, dropping anything
+ // malformed rather than failing the whole reply
+ List? entries = null;
+ var slots = reader.AggregateChildren();
+ while (slots.MoveNext())
+ {
+ if (TryParseEntry(ref slots.Value, out var entry))
+ {
+ (entries ??= new List()).Add(entry);
+ }
+ }
+
+ SetResult(message, new ClusterSlotStatsResult(AsReadOnly(entries)));
+ return true;
+ }
+ }
+
+ private static bool TryParseEntry(ref RespReader reader, out ClusterSlotStatsEntry entry)
+ {
+ entry = null!;
+ if (!reader.IsAggregate || reader.IsNull) return false;
+
+ var children = reader.AggregateChildren();
+ if (!children.MoveNext() || !children.Value.TryReadInt64(out var slot)) return false;
+ if (slot < SlotRange.MinSlot || slot > SlotRange.MaxSlot) return false;
+ if (!children.MoveNext() || !children.Value.IsAggregate || children.Value.IsNull) return false;
+
+ long keyCount = 0, cpuMicroseconds = -1, memoryBytes = -1, networkBytesIn = -1, networkBytesOut = -1;
+ List>? metadata = null;
+
+ // the metric map: flat pairs under RESP2, a map under RESP3; read nominally, never
+ // positionally - the set is configuration-dependent and documented as extensible
+ var fields = children.Value.AggregateChildren();
+ while (fields.MoveNext() && fields.Value.IsScalar)
+ {
+ ClusterSlotStatsField field;
+ string? unknownName = null;
+ unsafe
+ {
+ if (!fields.Value.TryParseScalar(&ClusterSlotStatsFieldMetadata.TryParse, out field))
+ {
+ field = ClusterSlotStatsField.Unknown;
+ }
+ }
+ if (field == ClusterSlotStatsField.Unknown) unknownName = fields.Value.ReadString();
+ if (!fields.MoveNext()) break; // metric name with no value: out of data
+ if (!fields.Value.TryReadInt64(out var value)) continue; // a non-numeric metric: skipped
+
+ switch (field)
+ {
+ case ClusterSlotStatsField.KeyCount:
+ keyCount = value;
+ break;
+ case ClusterSlotStatsField.CpuMicroseconds:
+ cpuMicroseconds = value;
+ break;
+ case ClusterSlotStatsField.MemoryBytes:
+ memoryBytes = value;
+ break;
+ case ClusterSlotStatsField.NetworkBytesIn:
+ networkBytesIn = value;
+ break;
+ case ClusterSlotStatsField.NetworkBytesOut:
+ networkBytesOut = value;
+ break;
+ default:
+ // a metric added by a future server: preserved, not discarded
+ if (unknownName is not null)
+ {
+ (metadata ??= new List>()).Add(new(unknownName, value));
+ }
+ break;
+ }
+ }
+
+ entry = new ClusterSlotStatsEntry(
+ (int)slot,
+ keyCount,
+ cpuMicroseconds,
+ memoryBytes,
+ networkBytesIn,
+ networkBytesOut,
+ AsReadOnly(metadata));
+ return true;
+ }
+
+ // empty collection expressions to an interface target compile to Array.Empty(), so the common case
+ // costs nothing; only a genuinely populated list pays for the wrapper
+ private static IReadOnlyList AsReadOnly(List? values)
+ {
+ if (values is null) return [];
+ return values.AsReadOnly();
+ }
+}
diff --git a/src/StackExchange.Redis/ClusterSlotStats.Server.cs b/src/StackExchange.Redis/ClusterSlotStats.Server.cs
new file mode 100644
index 000000000..95af15c17
--- /dev/null
+++ b/src/StackExchange.Redis/ClusterSlotStats.Server.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Threading.Tasks;
+
+namespace StackExchange.Redis;
+
+internal partial class RedisServer
+{
+ public ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None)
+ => ExecuteSync(GetClusterSlotStatsBySlotRangeMessage(startSlot, endSlot, flags), ClusterSlotStatsResult.Processor, server);
+
+ public Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None)
+ => ExecuteAsync(GetClusterSlotStatsBySlotRangeMessage(startSlot, endSlot, flags), ClusterSlotStatsResult.Processor, server);
+
+ public ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None)
+ => ExecuteSync(GetClusterSlotStatsOrderByMessage(metric, limit, order, flags), ClusterSlotStatsResult.Processor, server);
+
+ public Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None)
+ => ExecuteAsync(GetClusterSlotStatsOrderByMessage(metric, limit, order, flags), ClusterSlotStatsResult.Processor, server);
+
+ ///
+ /// Builds CLUSTER SLOT-STATS SLOTSRANGE start end. A node-local read, as
+ /// : it reports what the answering node tracks for itself, so it is
+ /// safe to replay against that node.
+ ///
+ internal static Message GetClusterSlotStatsBySlotRangeMessage(int startSlot, int endSlot, CommandFlags flags)
+ {
+ if (startSlot < SlotRange.MinSlot || startSlot > SlotRange.MaxSlot) throw new ArgumentOutOfRangeException(nameof(startSlot));
+ if (endSlot < startSlot || endSlot > SlotRange.MaxSlot) throw new ArgumentOutOfRangeException(nameof(endSlot));
+ return Message.Create(
+ -1,
+ flags.WithCategory(NodeLocalRead),
+ RedisCommand.CLUSTER,
+ new RedisValue[] { RedisLiterals.SLOT_STATS, RedisLiterals.SLOTSRANGE, startSlot, endSlot });
+ }
+
+ ///
+ /// Builds CLUSTER SLOT-STATS ORDERBY metric [LIMIT n] [ASC]; the server defaults (all slots,
+ /// descending) are expressed by omission.
+ ///
+ internal static Message GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric metric, long limit, Order order, CommandFlags flags)
+ {
+ if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit));
+ var metricValue = metric switch
+ {
+ ClusterSlotStatsMetric.KeyCount => KeyCountMetric,
+ ClusterSlotStatsMetric.CpuTime => CpuMetric,
+ ClusterSlotStatsMetric.MemoryBytes => MemoryMetric,
+ ClusterSlotStatsMetric.NetworkBytesIn => NetworkInMetric,
+ ClusterSlotStatsMetric.NetworkBytesOut => NetworkOutMetric,
+ _ => throw new ArgumentOutOfRangeException(nameof(metric)),
+ };
+ flags = flags.WithCategory(NodeLocalRead);
+ RedisValue[] values = (limit > 0, order) switch
+ {
+ (false, Order.Descending) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue],
+ (false, _) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.ASC],
+ (true, Order.Descending) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.LIMIT, limit],
+ (true, _) => [RedisLiterals.SLOT_STATS, RedisLiterals.ORDERBY, metricValue, RedisLiterals.LIMIT, limit, RedisLiterals.ASC],
+ };
+ return Message.Create(-1, flags, RedisCommand.CLUSTER, values);
+ }
+
+ // the ORDERBY metric tokens, as the server spells them (lower-case, hyphenated - unlike the
+ // upper-case option literals in RedisLiterals)
+ private static readonly RedisValue
+ KeyCountMetric = RedisValue.FromRaw("key-count"u8),
+ CpuMetric = RedisValue.FromRaw("cpu-usec"u8),
+ MemoryMetric = RedisValue.FromRaw("memory-bytes"u8),
+ NetworkInMetric = RedisValue.FromRaw("network-bytes-in"u8),
+ NetworkOutMetric = RedisValue.FromRaw("network-bytes-out"u8);
+}
diff --git a/src/StackExchange.Redis/ClusterSlotStats.cs b/src/StackExchange.Redis/ClusterSlotStats.cs
new file mode 100644
index 000000000..d7b3136ca
--- /dev/null
+++ b/src/StackExchange.Redis/ClusterSlotStats.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using RESPite;
+
+namespace StackExchange.Redis;
+
+public partial interface IServer
+{
+ ///
+ /// Obtains per-slot usage statistics from CLUSTER SLOT-STATS SLOTSRANGE, for the slots in an
+ /// inclusive range that are assigned to the answering node's shard, in ascending slot order.
+ ///
+ /// The first slot of the range (inclusive).
+ /// The last slot of the range (inclusive); must not be less than .
+ /// The command flags to use.
+ ///
+ /// Cluster-only: a non-cluster server answers with an error. Each node reports only the slots its own
+ /// shard serves, so slots in the range owned elsewhere are simply absent - ask each node to see the whole
+ /// cluster. key-count is always collected; the other metrics appear only when enabled via the
+ /// cluster-slot-stats-enabled server configuration - see .
+ ///
+ [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+ ClusterSlotStatsResult? ClusterSlotStatsBySlotRange(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None);
+
+ ///
+ [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+ Task ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, CommandFlags flags = CommandFlags.None);
+
+ ///
+ /// Obtains per-slot usage statistics from CLUSTER SLOT-STATS ORDERBY: the answering node's shard's
+ /// slots, sorted by a metric (ties broken by ascending slot number).
+ ///
+ /// The metric to sort by. Sorting by a metric whose collection is not enabled on the
+ /// server (via cluster-slot-stats-enabled) is refused by the server;
+ /// is always available.
+ /// The maximum number of slots to report; zero (the default) reports them all.
+ /// The sort direction; the server (and this) defaults to ,
+ /// which puts the busiest slots first.
+ /// The command flags to use.
+ ///
+ [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+ ClusterSlotStatsResult? ClusterSlotStatsOrderBy(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None);
+
+ ///
+ [Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+ Task ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric metric, long limit = 0, Order order = Order.Descending, CommandFlags flags = CommandFlags.None);
+}
+
+///
+/// A metric tracked per slot by CLUSTER SLOT-STATS.
+///
+[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+public enum ClusterSlotStatsMetric
+{
+ /// The number of keys in the slot (key-count); always collected.
+ KeyCount,
+
+ /// CPU time spent on the slot's keys (cpu-usec); collected when the cpu metric is enabled.
+ CpuTime,
+
+ /// Memory used by the slot's keys (memory-bytes); collected when the mem metric is
+ /// enabled, which additionally requires memory tracking from startup.
+ MemoryBytes,
+
+ /// Network bytes received for the slot's keys (network-bytes-in); collected when the net metric is enabled.
+ NetworkBytesIn,
+
+ /// Network bytes sent for the slot's keys (network-bytes-out); collected when the net metric is enabled.
+ NetworkBytesOut,
+}
+
+///
+/// The per-slot usage statistics reported by CLUSTER SLOT-STATS, as described by the node that answered.
+///
+[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+public sealed partial class ClusterSlotStatsResult
+{
+ internal ClusterSlotStatsResult(IReadOnlyList slots) => Slots = slots;
+
+ ///
+ /// Gets the per-slot entries, in the order the server reported them: ascending slot number for the
+ /// SLOTSRANGE form, metric order for the ORDERBY form.
+ ///
+ public IReadOnlyList Slots { get; }
+}
+
+///
+/// One slot's usage statistics from CLUSTER SLOT-STATS.
+///
+///
+/// Only is always collected; each other metric is null when the server is not
+/// collecting it (the cluster-slot-stats-enabled configuration), which is deliberately distinct from a
+/// collected zero.
+///
+[Experimental(Experiments.ClusterSlotStats, UrlFormat = Experiments.UrlFormat)]
+public sealed class ClusterSlotStatsEntry
+{
+ internal ClusterSlotStatsEntry(
+ int slot,
+ long keyCount,
+ long cpuMicroseconds,
+ long memoryBytes,
+ long networkBytesIn,
+ long networkBytesOut,
+ IReadOnlyList> metadata)
+ {
+ Slot = slot;
+ KeyCount = keyCount;
+ CpuMicroseconds = cpuMicroseconds;
+ MemoryBytes = memoryBytes < 0 ? null : memoryBytes;
+ NetworkBytesIn = networkBytesIn < 0 ? null : networkBytesIn;
+ NetworkBytesOut = networkBytesOut < 0 ? null : networkBytesOut;
+ Metadata = metadata;
+ }
+
+ ///
+ /// Gets the hash slot this entry describes.
+ ///
+ public int Slot { get; }
+
+ ///
+ /// Gets the number of keys in the slot.
+ ///
+ public long KeyCount { get; }
+
+ internal long CpuMicroseconds { get; } = -1;
+
+ ///
+ /// Gets the CPU time spent serving the slot's keys, or null when the cpu metric is not
+ /// being collected.
+ ///
+ public TimeSpan? CpuTime => CpuMicroseconds < 0 ? null : HotKeysResult.NonNegativeMicroseconds(CpuMicroseconds);
+
+ ///
+ /// Gets the memory used by the slot's keys, or null when the mem metric is not being
+ /// collected.
+ ///
+ public long? MemoryBytes { get; }
+
+ ///
+ /// Gets the network bytes received for the slot's keys, or null when the net metric is not
+ /// being collected.
+ ///
+ public long? NetworkBytesIn { get; }
+
+ ///
+ /// Gets the network bytes sent for the slot's keys, or null when the net metric is not
+ /// being collected.
+ ///
+ public long? NetworkBytesOut { get; }
+
+ ///
+ /// Gets the metric entries reported for this slot that this library does not recognize. The metric set is
+ /// configuration-dependent and has grown before, so these are preserved rather than discarded; the
+ /// recognized metrics are surfaced as the typed properties instead of appearing here.
+ ///
+ public IReadOnlyList> Metadata { get; }
+
+ ///
+ public override string ToString() => $"slot {Slot}: {KeyCount} keys";
+}
diff --git a/src/StackExchange.Redis/ClusterSlotStatsField.cs b/src/StackExchange.Redis/ClusterSlotStatsField.cs
new file mode 100644
index 000000000..ee9cc1bd0
--- /dev/null
+++ b/src/StackExchange.Redis/ClusterSlotStatsField.cs
@@ -0,0 +1,55 @@
+using System;
+using RESPite;
+
+namespace StackExchange.Redis;
+
+///
+/// Metrics that can appear in a CLUSTER SLOT-STATS per-slot map.
+///
+internal enum ClusterSlotStatsField
+{
+ ///
+ /// Unknown or unrecognized metric.
+ ///
+ [AsciiHash("")]
+ Unknown = 0,
+
+ ///
+ /// The number of keys in the slot.
+ ///
+ [AsciiHash("key-count")]
+ KeyCount,
+
+ ///
+ /// CPU time spent on the slot's keys, in microseconds.
+ ///
+ [AsciiHash("cpu-usec")]
+ CpuMicroseconds,
+
+ ///
+ /// Memory used by the slot's keys, in bytes.
+ ///
+ [AsciiHash("memory-bytes")]
+ MemoryBytes,
+
+ ///
+ /// Network bytes received for the slot's keys.
+ ///
+ [AsciiHash("network-bytes-in")]
+ NetworkBytesIn,
+
+ ///
+ /// Network bytes sent for the slot's keys.
+ ///
+ [AsciiHash("network-bytes-out")]
+ NetworkBytesOut,
+}
+
+///
+/// Metadata and parsing methods for ClusterSlotStatsField.
+///
+internal static partial class ClusterSlotStatsFieldMetadata
+{
+ [AsciiHash]
+ internal static partial bool TryParse(ReadOnlySpan value, out ClusterSlotStatsField field);
+}
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index ab058de62..d901d5a48 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1 +1,23 @@
#nullable enable
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.CpuTime.get -> System.TimeSpan?
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.KeyCount.get -> long
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.MemoryBytes.get -> long?
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.Metadata.get -> System.Collections.Generic.IReadOnlyList>!
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.NetworkBytesIn.get -> long?
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.NetworkBytesOut.get -> long?
+[SER012]StackExchange.Redis.ClusterSlotStatsEntry.Slot.get -> int
+[SER012]override StackExchange.Redis.ClusterSlotStatsEntry.ToString() -> string!
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric.CpuTime = 1 -> StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric.KeyCount = 0 -> StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric.MemoryBytes = 2 -> StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric.NetworkBytesIn = 3 -> StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsMetric.NetworkBytesOut = 4 -> StackExchange.Redis.ClusterSlotStatsMetric
+[SER012]StackExchange.Redis.ClusterSlotStatsResult
+[SER012]StackExchange.Redis.ClusterSlotStatsResult.Slots.get -> System.Collections.Generic.IReadOnlyList!
+[SER012]StackExchange.Redis.IServer.ClusterSlotStatsBySlotRange(int startSlot, int endSlot, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ClusterSlotStatsResult?
+[SER012]StackExchange.Redis.IServer.ClusterSlotStatsBySlotRangeAsync(int startSlot, int endSlot, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER012]StackExchange.Redis.IServer.ClusterSlotStatsOrderBy(StackExchange.Redis.ClusterSlotStatsMetric metric, long limit = 0, StackExchange.Redis.Order order = StackExchange.Redis.Order.Descending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ClusterSlotStatsResult?
+[SER012]StackExchange.Redis.IServer.ClusterSlotStatsOrderByAsync(StackExchange.Redis.ClusterSlotStatsMetric metric, long limit = 0, StackExchange.Redis.Order order = StackExchange.Redis.Order.Descending, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+StackExchange.Redis.RedisFeatures.ClusterSlotStats.get -> bool
diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs
index 8f876a438..ac9814166 100644
--- a/src/StackExchange.Redis/RedisFeatures.cs
+++ b/src/StackExchange.Redis/RedisFeatures.cs
@@ -322,6 +322,11 @@ public RedisFeatures(Version version)
///
public bool HashImport => Version.IsAtLeast(v8_10_0);
+ ///
+ /// Are the CLUSTER SLOT-STATS per-slot usage statistics available?
+ ///
+ public bool ClusterSlotStats => Version.IsAtLeast(v8_2_0_rc1);
+
#pragma warning restore 1629 // Documentation text should end with a period.
///
diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs
index 58d4632c9..8ae916464 100644
--- a/src/StackExchange.Redis/RedisLiterals.cs
+++ b/src/StackExchange.Redis/RedisLiterals.cs
@@ -100,6 +100,7 @@ public static readonly RedisValue
OBO = RedisValue.FromRaw("OBO"u8),
ONE = RedisValue.FromRaw("ONE"u8),
OR = RedisValue.FromRaw("OR"u8),
+ ORDERBY = RedisValue.FromRaw("ORDERBY"u8),
PATTERN = RedisValue.FromRaw("PATTERN"u8),
PAUSE = RedisValue.FromRaw("PAUSE"u8),
PERSIST = RedisValue.FromRaw("PERSIST"u8),
@@ -124,6 +125,8 @@ public static readonly RedisValue
SETINFO = RedisValue.FromRaw("SETINFO"u8),
SETNAME = RedisValue.FromRaw("SETNAME"u8),
SKIPME = RedisValue.FromRaw("SKIPME"u8),
+ SLOT_STATS = RedisValue.FromRaw("SLOT-STATS"u8),
+ SLOTSRANGE = RedisValue.FromRaw("SLOTSRANGE"u8),
STATS = RedisValue.FromRaw("STATS"u8),
STOP = RedisValue.FromRaw("STOP"u8),
STORE = RedisValue.FromRaw("STORE"u8),
diff --git a/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs b/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs
new file mode 100644
index 000000000..c37bf0827
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs
@@ -0,0 +1,163 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// CLUSTER SLOT-STATS against a real cluster; the exact wire bytes and reply-shape matrix live in
+/// . Runs per protocol, since the per-slot metrics
+/// are a map under RESP3 and flat pairs under RESP2.
+///
+[RunPerProtocol]
+public class ClusterSlotStatsTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture)
+{
+ protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000";
+
+ private static void RequireSlotStats(IServer server)
+ => Assert.SkipUnless(server.Features.ClusterSlotStats, $"CLUSTER SLOT-STATS needs 8.2, server is {server.Version}");
+
+ [Fact]
+ public async Task RoutedWriteIsCountedInItsSlot()
+ {
+ await using var conn = Create(allowAdmin: true);
+ RedisKey key = Me();
+ var server = conn.GetServer(key); // the primary that owns the key
+ RequireSlotStats(server);
+
+ await conn.GetDatabase().StringSetAsync(key, "x");
+ var slot = conn.HashSlot(key);
+
+ var result = await server.ClusterSlotStatsBySlotRangeAsync(slot, slot);
+ Assert.NotNull(result);
+ var entry = Assert.Single(result.Slots);
+ Assert.Equal(slot, entry.Slot);
+ Log($"slot {slot}: {entry.KeyCount} keys");
+ Assert.True(entry.KeyCount >= 1, "the slot we just wrote to should hold at least our key");
+ }
+
+ [Fact]
+ public async Task WholeRangeReportsOnlyTheNodesOwnSlots()
+ {
+ await using var conn = Create(allowAdmin: true);
+ var server = conn.GetServer(conn.GetEndPoints()[0]);
+ RequireSlotStats(server);
+
+ var result = await server.ClusterSlotStatsBySlotRangeAsync(SlotRange.MinSlot, SlotRange.MaxSlot);
+ Assert.NotNull(result);
+ Assert.NotEmpty(result.Slots);
+ Log($"{result.Slots.Count} slots reported by {server.EndPoint}");
+
+ // ascending slot order, each slot at most once, all in range
+ for (int i = 1; i < result.Slots.Count; i++)
+ {
+ Assert.True(result.Slots[i - 1].Slot < result.Slots[i].Slot, "SLOTSRANGE results should be in ascending slot order");
+ }
+ Assert.All(result.Slots, s => Assert.InRange(s.Slot, SlotRange.MinSlot, SlotRange.MaxSlot));
+ }
+
+ [Fact]
+ public async Task OrderByHonoursLimitAndOrder()
+ {
+ await using var conn = Create(allowAdmin: true);
+ var server = conn.GetServer(conn.GetEndPoints()[0]);
+ RequireSlotStats(server);
+
+ // make sure at least two slots on this node have different key counts
+ var db = conn.GetDatabase();
+ for (int i = 0; i < 10; i++)
+ {
+ await db.StringSetAsync($"{Me()}:{i}", i);
+ }
+
+ var top = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 5);
+ Assert.NotNull(top);
+ Assert.InRange(top.Slots.Count, 1, 5);
+ for (int i = 1; i < top.Slots.Count; i++)
+ {
+ Assert.True(top.Slots[i - 1].KeyCount >= top.Slots[i].KeyCount, "descending is the default");
+ }
+
+ var bottom = await server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.KeyCount, limit: 5, order: Order.Ascending);
+ Assert.NotNull(bottom);
+ Assert.InRange(bottom.Slots.Count, 1, 5);
+ for (int i = 1; i < bottom.Slots.Count; i++)
+ {
+ Assert.True(bottom.Slots[i - 1].KeyCount <= bottom.Slots[i].KeyCount, "ascending when asked");
+ }
+
+ Log($"top: {string.Join(", ", top.Slots.Select(s => s.ToString()))}");
+ Log($"bottom: {string.Join(", ", bottom.Slots.Select(s => s.ToString()))}");
+ }
+
+ [Fact]
+ public async Task EnablingMetricsSurfacesThem()
+ {
+ await using var conn = Create(allowAdmin: true, shared: false);
+ RedisKey key = Me();
+ var server = conn.GetServer(key);
+ RequireSlotStats(server);
+
+ var original = (await server.ConfigGetAsync("cluster-slot-stats-enabled")).Single().Value;
+ Log($"cluster-slot-stats-enabled was: '{original}'");
+ try
+ {
+ // cpu and net can be enabled at runtime; mem needs memory tracking from server startup
+ await server.ConfigSetAsync("cluster-slot-stats-enabled", "cpu net");
+
+ // generate some traffic on a slot this node owns, after enabling
+ var db = conn.GetDatabase();
+ var slot = conn.HashSlot(key);
+ for (int i = 0; i < 20; i++)
+ {
+ await db.StringSetAsync(key, i);
+ await db.StringGetAsync(key);
+ }
+
+ var result = await server.ClusterSlotStatsBySlotRangeAsync(slot, slot);
+ Assert.NotNull(result);
+ var entry = Assert.Single(result.Slots);
+ Log(entry.ToString());
+ Log($"cpu: {entry.CpuTime}, in: {entry.NetworkBytesIn}, out: {entry.NetworkBytesOut}, mem: {entry.MemoryBytes}");
+
+ Assert.NotNull(entry.CpuTime);
+ Assert.NotNull(entry.NetworkBytesIn);
+ Assert.NotNull(entry.NetworkBytesOut);
+ Assert.True(entry.NetworkBytesIn > 0, "we sent this slot twenty SETs; bytes-in should be non-zero");
+ Assert.Null(entry.MemoryBytes); // not enabled - null, not zero
+ }
+ finally
+ {
+ await server.ConfigSetAsync("cluster-slot-stats-enabled", original);
+ }
+ }
+
+ [Fact]
+ public async Task OrderByDisabledMetricIsRefused()
+ {
+ await using var conn = Create(allowAdmin: true);
+ var server = conn.GetServer(conn.GetEndPoints()[0]);
+ RequireSlotStats(server);
+
+ var enabled = (await server.ConfigGetAsync("cluster-slot-stats-enabled")).Single().Value.ToString();
+ Assert.SkipWhen(enabled.Contains("mem") || enabled.Contains("yes"), $"memory collection is enabled ('{enabled}'), so the server would not refuse it");
+
+ var ex = await Assert.ThrowsAsync(
+ () => server.ClusterSlotStatsOrderByAsync(ClusterSlotStatsMetric.MemoryBytes));
+ Log(ex.Message);
+ Assert.Contains("ORDERBY", ex.Message);
+ }
+
+ [Fact]
+ public async Task StandaloneServerRefuses()
+ {
+ await using var conn = Create(configuration: TestConfig.Current.PrimaryServerAndPort, allowAdmin: true, shared: false);
+ var server = conn.GetServer(conn.GetEndPoints()[0]);
+ RequireSlotStats(server);
+
+ var ex = await Assert.ThrowsAsync(
+ () => server.ClusterSlotStatsBySlotRangeAsync(0, 100));
+ Log(ex.Message);
+ Assert.Contains("cluster support disabled", ex.Message);
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs
new file mode 100644
index 000000000..a21f72dd2
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+public class ClusterSlotStatsRoundTrip(ITestOutputHelper log)
+{
+ // a RESP2 reply with every documented metric present
+ private const string FullEntryResp2 =
+ "*1\r\n*2\r\n:100\r\n*10\r\n"
+ + "$9\r\nkey-count\r\n:42\r\n"
+ + "$8\r\ncpu-usec\r\n:1500\r\n"
+ + "$12\r\nmemory-bytes\r\n:2048\r\n"
+ + "$16\r\nnetwork-bytes-in\r\n:300\r\n"
+ + "$17\r\nnetwork-bytes-out\r\n:400\r\n";
+
+ // the same payload as a RESP3 map
+ private const string FullEntryResp3 =
+ "*1\r\n*2\r\n:100\r\n%5\r\n"
+ + "$9\r\nkey-count\r\n:42\r\n"
+ + "$8\r\ncpu-usec\r\n:1500\r\n"
+ + "$12\r\nmemory-bytes\r\n:2048\r\n"
+ + "$16\r\nnetwork-bytes-in\r\n:300\r\n"
+ + "$17\r\nnetwork-bytes-out\r\n:400\r\n";
+
+ private static void AssertFullEntry(ClusterSlotStatsResult? result)
+ {
+ Assert.NotNull(result);
+ var entry = Assert.Single(result.Slots);
+ Assert.Equal(100, entry.Slot);
+ Assert.Equal(42, entry.KeyCount);
+ Assert.Equal(TimeSpan.FromTicks(1500 * 10), entry.CpuTime); // 1500µs; 1 tick = 0.1µs
+ Assert.Equal(2048, entry.MemoryBytes);
+ Assert.Equal(300, entry.NetworkBytesIn);
+ Assert.Equal(400, entry.NetworkBytesOut);
+ Assert.Empty(entry.Metadata);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SlotsRange_FullMap_Resp2_RoundTrips()
+ {
+ var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(100, 200, CommandFlags.None);
+ const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$3\r\n100\r\n$3\r\n200\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, FullEntryResp2, log: log);
+ AssertFullEntry(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SlotsRange_FullMap_Resp3_RoundTrips()
+ {
+ var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(100, 200, CommandFlags.None);
+ const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$3\r\n100\r\n$3\r\n200\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, FullEntryResp3, protocol: RedisProtocol.Resp3, log: log);
+ AssertFullEntry(result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task OrderBy_Defaults_OmitsLimitAndOrder()
+ {
+ // the server defaults (all slots, descending) are expressed by omission
+ var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.KeyCount, 0, Order.Descending, CommandFlags.None);
+ const string requestResp = "*4\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$9\r\nkey-count\r\n";
+
+ // key-count only: the other metrics are not being collected, and must come back null
+ const string responseResp = "*2\r\n"
+ + "*2\r\n:8000\r\n*2\r\n$9\r\nkey-count\r\n:7\r\n"
+ + "*2\r\n:12\r\n*2\r\n$9\r\nkey-count\r\n:3\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, responseResp, log: log);
+ Assert.NotNull(result);
+ Assert.Equal(2, result.Slots.Count);
+ Assert.Equal(8000, result.Slots[0].Slot); // server order is preserved, not re-sorted
+ Assert.Equal(7, result.Slots[0].KeyCount);
+ Assert.Null(result.Slots[0].CpuTime);
+ Assert.Null(result.Slots[0].MemoryBytes);
+ Assert.Null(result.Slots[0].NetworkBytesIn);
+ Assert.Null(result.Slots[0].NetworkBytesOut);
+ Assert.Equal(12, result.Slots[1].Slot);
+ Assert.Equal(3, result.Slots[1].KeyCount);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task OrderBy_WithLimit_WritesLimit()
+ {
+ var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.CpuTime, 5, Order.Descending, CommandFlags.None);
+ const string requestResp = "*6\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$8\r\ncpu-usec\r\n$5\r\nLIMIT\r\n$1\r\n5\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log);
+ Assert.NotNull(result);
+ Assert.Empty(result.Slots);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task OrderBy_AscendingWithLimit_WritesBoth()
+ {
+ var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.NetworkBytesOut, 3, Order.Ascending, CommandFlags.None);
+ const string requestResp = "*7\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$17\r\nnetwork-bytes-out\r\n$5\r\nLIMIT\r\n$1\r\n3\r\n$3\r\nASC\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log);
+ Assert.NotNull(result);
+ Assert.Empty(result.Slots);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task OrderBy_AscendingNoLimit_WritesAscOnly()
+ {
+ var msg = RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.MemoryBytes, 0, Order.Ascending, CommandFlags.None);
+ const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$7\r\nORDERBY\r\n$12\r\nmemory-bytes\r\n$3\r\nASC\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*0\r\n", log: log);
+ Assert.NotNull(result);
+ Assert.Empty(result.Slots);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task UnknownMetric_IsPreservedAsMetadata()
+ {
+ // the metric set is documented as extensible; a metric this library has never
+ // heard of must survive as metadata, not be dropped
+ var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 0, CommandFlags.None);
+ const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$1\r\n0\r\n$1\r\n0\r\n";
+ const string responseResp = "*1\r\n*2\r\n:0\r\n*4\r\n"
+ + "$9\r\nkey-count\r\n:1\r\n"
+ + "$11\r\nwidget-usec\r\n:99\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, responseResp, log: log);
+ Assert.NotNull(result);
+ var entry = Assert.Single(result.Slots);
+ Assert.Equal(1, entry.KeyCount);
+ var extra = Assert.Single(entry.Metadata);
+ Assert.Equal("widget-usec", extra.Key);
+ Assert.Equal(99, extra.Value);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task NilReply_IsNull()
+ {
+ var msg = RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 100, CommandFlags.None);
+ const string requestResp = "*5\r\n$7\r\nCLUSTER\r\n$10\r\nSLOT-STATS\r\n$10\r\nSLOTSRANGE\r\n$1\r\n0\r\n$3\r\n100\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ClusterSlotStatsResult.Processor, requestResp, "*-1\r\n", log: log);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void SlotsRange_InvalidSlots_AreRejected()
+ {
+ Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(-1, 100, CommandFlags.None));
+ Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(0, 16384, CommandFlags.None));
+ Assert.Throws(() => RedisServer.GetClusterSlotStatsBySlotRangeMessage(200, 100, CommandFlags.None));
+ }
+
+ [Fact]
+ public void OrderBy_InvalidArguments_AreRejected()
+ {
+ Assert.Throws(() => RedisServer.GetClusterSlotStatsOrderByMessage(ClusterSlotStatsMetric.KeyCount, -1, Order.Descending, CommandFlags.None));
+ Assert.Throws(() => RedisServer.GetClusterSlotStatsOrderByMessage((ClusterSlotStatsMetric)42, 0, Order.Descending, CommandFlags.None));
+ }
+}