From 16bd01241addfb3c9fa21901f29f0bbc987c95cd Mon Sep 17 00:00:00 2001 From: mgravell Date: Sat, 7 Nov 2026 10:15:49 +0000 Subject: [PATCH] CLUSTER SLOT-STATS (8.2): per-slot usage statistics on IServer, experimental SER012 IServer.ClusterSlotStatsBySlotRange(Async) and ClusterSlotStatsOrderBy(Async), modelled on the CLUSTER SLOTS file layout and the HOTKEYS field handling: node-local read, lenient nominal parsing, config-gated metrics as nullable properties (absent != zero), unknown metrics preserved as Metadata, cpu-usec surfaced as TimeSpan. RedisFeatures.ClusterSlotStats gates on 8.2 RC1. Promotes the CommandCoverage 'deferred' row. --- Directory.Build.props | 2 +- docs/ClusterSlotStats.md | 62 ++++++ docs/CommandCoverage.md | 1 - docs/exp/SER012.md | 25 +++ docs/index.md | 1 + notes/cluster-slot-stats/plan.md | 181 ++++++++++++++++++ src/RESPite/Shared/Experiments.cs | 1 + .../ClusterSlotStats.ResultProcessor.cs | 115 +++++++++++ .../ClusterSlotStats.Server.cs | 71 +++++++ src/StackExchange.Redis/ClusterSlotStats.cs | 163 ++++++++++++++++ .../ClusterSlotStatsField.cs | 55 ++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 22 +++ src/StackExchange.Redis/RedisFeatures.cs | 5 + src/StackExchange.Redis/RedisLiterals.cs | 3 + .../ClusterSlotStatsTests.cs | 163 ++++++++++++++++ .../ClusterSlotStatsRoundTrip.cs | 162 ++++++++++++++++ 16 files changed, 1030 insertions(+), 2 deletions(-) create mode 100644 docs/ClusterSlotStats.md create mode 100644 docs/exp/SER012.md create mode 100644 notes/cluster-slot-stats/plan.md create mode 100644 src/StackExchange.Redis/ClusterSlotStats.ResultProcessor.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStats.Server.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStats.cs create mode 100644 src/StackExchange.Redis/ClusterSlotStatsField.cs create mode 100644 tests/StackExchange.Redis.Tests/ClusterSlotStatsTests.cs create mode 100644 tests/StackExchange.Redis.Tests/RoundTripUnitTests/ClusterSlotStatsRoundTrip.cs diff --git a/Directory.Build.props b/Directory.Build.props index 55c0088feb..3f88b7ba17 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 0000000000..efff961105 --- /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 c055d71ffd..f8ff94211e 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 0000000000..115a0eee81 --- /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 a65e482b9c..64cf22ac5f 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 0000000000..a9c65ab3f2 --- /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 93fd067f8e..c8aa8f2022 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 0000000000..bbc7ede15f --- /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 0000000000..95af15c17e --- /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 0000000000..d7b3136ca9 --- /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 0000000000..ee9cc1bd0e --- /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 ab058de62d..d901d5a485 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 8f876a4382..ac98141662 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 58d4632c96..8ae916464e 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 0000000000..c37bf0827b --- /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 0000000000..a21f72dd26 --- /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)); + } +}