镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<MSBuildWarningsAsMessages>NETSDK1069</MSBuildWarningsAsMessages>
<!-- SER001/SER002/SER003/SER006/SER007/SER008/SER010 retired (vector sets, Redis 8.4/8.6/8.8/8.10 features,
geo-redundant failover and maintenance notifications, no longer experimental); IDs reserved, see Experiments.cs -->
<NoWarn>$(NoWarn);NU5105;NU1507;SER004;SER005;SER009</NoWarn>
<NoWarn>$(NoWarn);NU5105;NU1507;SER004;SER005;SER009;SER012</NoWarn>
<PackageReleaseNotes>https://github.com/StackExchange/StackExchange.Redis/releases</PackageReleaseNotes>
<PackageProjectUrl>https://seredis.dev/</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
62 changes: 62 additions & 0 deletions docs/ClusterSlotStats.md
Original file line number Diff line number Diff line change
@@ -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 ...`.
1 change: 0 additions & 1 deletion docs/CommandCoverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions docs/exp/SER012.md
Original file line number Diff line number Diff line change
@@ -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>$(NoWarn);SER012</NoWarn>
```

or more granularly / locally in C#:

``` c#
#pragma warning disable SER012
```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions notes/cluster-slot-stats/plan.md
Original file line number Diff line number Diff line change
@@ -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 <SLOTSRANGE start end | ORDERBY metric [LIMIT n] [ASC|DESC]>`, 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, <literal>...)`,
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<ClusterSlotStatsResult?> 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<ClusterSlotStatsResult?> 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<ClusterSlotStatsEntry> 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<KeyValuePair<string, long>> 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.
1 change: 1 addition & 0 deletions src/RESPite/Shared/Experiments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading