From daab896a11d94933deab8cb230ec2a7ad21dc5ee Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sat, 7 Feb 2026 16:42:53 +0000 Subject: [PATCH 01/27] propose API for HOTKEYS --- src/StackExchange.Redis/Interfaces/IServer.cs | 185 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 31 +++ 2 files changed, 216 insertions(+) diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 8e4178fc9..24a63d577 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; +using StackExchange.Redis; namespace StackExchange.Redis { @@ -804,6 +805,49 @@ public partial interface IServer : IRedis /// Task[][]> SentinelSentinelsAsync(string serviceName, CommandFlags flags = CommandFlags.None); + +/* + + [COUNT k] + [DURATION duration] + [SAMPLE ratio] + [SLOTS count slot…] + */ + /// + /// Start a new HOTKEYS profiling session. + /// + /// The metrics to record during this capture (defaults to "all"). + /// The total number of operations to profile. + /// The duration of this profiling session. + /// Profiling frequency; effectively: measure every Nth command. + /// The key-slots to record during this capture (defaults to "all"). + /// The command flags to use. + void HotKeysStart( + HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None); + + /// + /// Stop the current HOTKEYS capture, if any. + /// + /// The command flags to use. + void HotKeysStop(CommandFlags flags = CommandFlags.None); + + /// + /// Discard the last HOTKEYS capture data, if any. + /// + /// The command flags to use. + void HotKeysReset(CommandFlags flags = CommandFlags.None); + + /// + /// Fetch the most recent HOTKEYS profiling data. + /// + /// The command flags to use. + /// The data captured during HOTKEYS profiling. + HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None); } internal static class IServerExtensions @@ -816,3 +860,144 @@ internal static class IServerExtensions internal static void SimulateConnectionFailure(this IServer server, SimulatedFailureType failureType) => (server as RedisServer)?.SimulateConnectionFailure(failureType); } } + +/// +/// Metrics to record during HOTKEYS profiling. +/// +[Flags] +public enum HotKeysMetrics +{ + /// + /// Capture CPU time. + /// + Cpu = 1 << 0, + + /// + /// Capture network bytes. + /// + Network = 1 << 1, +} + +/// +/// Captured data from HOTKEYS profiling. +/// +public sealed class HotKeysResult +{ + internal HotKeysResult() + { + } + + /// + /// Is the capture currently active? + /// + public bool TrackingActive { get; internal set; } + + /// + /// Profiling frequency; effectively: measure every Nth command. + /// + public long SampleRatio { get; internal set; } + + /// + /// The total CPU measured for all commands in all slots. + /// + public TimeSpan TotalCpuTime { get; internal set; } + + /// + /// The total network usage measured for all commands in all slots. + /// + public long TotalNetworkBytes { get; internal set; } + + internal long CollectionStartTimeUnixMilliseconds { get; set; } + + /// + /// The start time of the capture. + /// + public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); + + internal long CollectionDurationMilliseconds { get; set; } + + /// + /// The duration of the capture. + /// + public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); + + internal long TotalCpuTimeUserMilliseconds { get; set; } + + /// + /// The total user CPU time measured. + /// + public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); + + internal long TotalCpuTimeSystemMilliseconds { get; set; } + + /// + /// The total system CPU measured. + /// + public TimeSpan TotalCpuTimeSystem => TimeSpan.FromMilliseconds(TotalCpuTimeSystemMilliseconds); + + /// + /// The total network data measured. + /// + public long TotalNetworkBytes2 { get; internal set; } // total-net-bytes vs net-bytes-all-commands-all-slots + + // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking + // for a particular key (lookup), but rather: is likely to want to list them for display; this way, + // we'll preserve the server's display order. + + /// + /// Hot keys, as measured by CPU activity. + /// + public MetricKeyCpu[] CpuByKey { get; internal set; } = []; + + /// + /// Hot keys, as measured by network activity. + /// + public MetricKeyBytes[] NetworkBytesByKey { get; internal set; } = []; + + private const long TicksPerMicroSeconds = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer + + /// + /// A hot key, as measured by CPU activity. + /// + /// The key observed. + /// The time taken, in microseconds. + public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) + { + private readonly RedisKey _key = key; + + /// + /// The key observed. + /// + public RedisKey Key => _key; + + /// + /// The time taken, in microseconds. + /// + public long MicroSeconds => microSeconds; + + /// + /// The time taken. + /// + public TimeSpan Duration => TimeSpan.FromTicks(microSeconds / TicksPerMicroSeconds); + } + + /// + /// A hot key, as measured by network activity. + /// + /// The key observed. + /// The network activity, in bytes. + public readonly struct MetricKeyBytes(in RedisKey key, long bytes) + { + private readonly RedisKey _key = key; + + /// + /// The key observed. + /// + public RedisKey Key => _key; + + /// + /// The network activity, in bytes. + /// + public long Bytes => bytes; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..9d94db345 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,32 @@ #nullable enable +HotKeysMetrics +HotKeysMetrics.Cpu = 1 -> HotKeysMetrics +HotKeysMetrics.Network = 2 -> HotKeysMetrics +HotKeysMetrics.None = 0 -> HotKeysMetrics +HotKeysResult +HotKeysResult.CollectionDuration.get -> System.TimeSpan +HotKeysResult.CollectionStartTime.get -> System.DateTime +HotKeysResult.CpuByKey.get -> HotKeysResult.MetricKeyCpu[]! +HotKeysResult.MetricKeyBytes +HotKeysResult.MetricKeyBytes.Bytes.get -> long +HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey +HotKeysResult.MetricKeyBytes.MetricKeyBytes() -> void +HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void +HotKeysResult.MetricKeyCpu +HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan +HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey +HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void +HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void +HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long +HotKeysResult.NetworkBytesByKey.get -> HotKeysResult.MetricKeyBytes[]! +HotKeysResult.SampleRatio.get -> long +HotKeysResult.TotalCpuTime.get -> System.TimeSpan +HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan +HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan +HotKeysResult.TotalNetworkBytes.get -> long +HotKeysResult.TotalNetworkBytes2.get -> long +HotKeysResult.TrackingActive.get -> bool +StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> HotKeysResult! +StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysStart(HotKeysMetrics metrics = (HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void From 5cd93da2dfd3f52a064c38ba6cd0e93872def9d7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 08:43:01 +0000 Subject: [PATCH 02/27] stubs --- src/StackExchange.Redis/CommandMap.cs | 4 +- src/StackExchange.Redis/Enums/RedisCommand.cs | 2 + src/StackExchange.Redis/HotKeys.cs | 276 ++++++++++++++++++ src/StackExchange.Redis/Interfaces/IServer.cs | 185 ------------ src/StackExchange.Redis/Message.cs | 2 + .../PublicAPI/PublicAPI.Unshipped.txt | 61 ++-- src/StackExchange.Redis/RedisLiterals.cs | 1 + src/StackExchange.Redis/RedisServer.cs | 2 +- 8 files changed, 316 insertions(+), 217 deletions(-) create mode 100644 src/StackExchange.Redis/HotKeys.cs diff --git a/src/StackExchange.Redis/CommandMap.cs b/src/StackExchange.Redis/CommandMap.cs index 663c61b36..683e51219 100644 --- a/src/StackExchange.Redis/CommandMap.cs +++ b/src/StackExchange.Redis/CommandMap.cs @@ -41,7 +41,7 @@ public sealed class CommandMap RedisCommand.BGREWRITEAOF, RedisCommand.BGSAVE, RedisCommand.CLIENT, RedisCommand.CLUSTER, RedisCommand.CONFIG, RedisCommand.DBSIZE, RedisCommand.DEBUG, RedisCommand.FLUSHALL, RedisCommand.FLUSHDB, RedisCommand.INFO, RedisCommand.LASTSAVE, RedisCommand.MONITOR, RedisCommand.REPLICAOF, - RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, + RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, RedisCommand.HOTKEYS, }); /// @@ -65,7 +65,7 @@ public sealed class CommandMap RedisCommand.BGREWRITEAOF, RedisCommand.BGSAVE, RedisCommand.CLIENT, RedisCommand.CLUSTER, RedisCommand.CONFIG, RedisCommand.DBSIZE, RedisCommand.DEBUG, RedisCommand.FLUSHALL, RedisCommand.FLUSHDB, RedisCommand.INFO, RedisCommand.LASTSAVE, RedisCommand.MONITOR, RedisCommand.REPLICAOF, - RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, + RedisCommand.SAVE, RedisCommand.SHUTDOWN, RedisCommand.SLAVEOF, RedisCommand.SLOWLOG, RedisCommand.SYNC, RedisCommand.TIME, RedisCommand.HOTKEYS, // supported by envoy but not enabled by stack exchange // RedisCommand.BITFIELD, diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 14f304a35..37baae758 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -81,6 +81,7 @@ internal enum RedisCommand HLEN, HMGET, HMSET, + HOTKEYS, HPERSIST, HPEXPIRE, HPEXPIREAT, @@ -430,6 +431,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.HKEYS: case RedisCommand.HLEN: case RedisCommand.HMGET: + case RedisCommand.HOTKEYS: case RedisCommand.HPEXPIRETIME: case RedisCommand.HPTTL: case RedisCommand.HRANDFIELD: diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs new file mode 100644 index 000000000..b66588a93 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.cs @@ -0,0 +1,276 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public partial interface IServer +{ + /* + HOTKEYS + + [COUNT k] + [DURATION duration] + [SAMPLE ratio] + [SLOTS count slot…] + */ + + /// + /// Start a new HOTKEYS profiling session. + /// + /// The metrics to record during this capture (defaults to "all"). + /// The total number of operations to profile. + /// The duration of this profiling session. + /// Profiling frequency; effectively: measure every Nth command. + /// The key-slots to record during this capture (defaults to "all"). + /// The command flags to use. + void HotKeysStart( + HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None); + + /// + /// Start a new HOTKEYS profiling session. + /// + /// The metrics to record during this capture (defaults to "all"). + /// The total number of operations to profile. + /// The duration of this profiling session. + /// Profiling frequency; effectively: measure every Nth command. + /// The key-slots to record during this capture (defaults to "all"). + /// The command flags to use. + Task HotKeysStartAsync( + HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None); + + /// + /// Stop the current HOTKEYS capture, if any. + /// + /// The command flags to use. + void HotKeysStop(CommandFlags flags = CommandFlags.None); + + /// + /// Stop the current HOTKEYS capture, if any. + /// + /// The command flags to use. + Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Discard the last HOTKEYS capture data, if any. + /// + /// The command flags to use. + void HotKeysReset(CommandFlags flags = CommandFlags.None); + + /// + /// Discard the last HOTKEYS capture data, if any. + /// + /// The command flags to use. + Task HotKeysResetAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Fetch the most recent HOTKEYS profiling data. + /// + /// The command flags to use. + /// The data captured during HOTKEYS profiling. + HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None); + + /// + /// Fetch the most recent HOTKEYS profiling data. + /// + /// The command flags to use. + /// The data captured during HOTKEYS profiling. + Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None); +} + +/// +/// Metrics to record during HOTKEYS profiling. +/// +[Flags] +public enum HotKeysMetrics +{ + /// + /// Capture CPU time. + /// + Cpu = 1 << 0, + + /// + /// Capture network bytes. + /// + Network = 1 << 1, +} + +/// +/// Captured data from HOTKEYS profiling. +/// +public sealed class HotKeysResult +{ + internal HotKeysResult() + { + } + + /// + /// Indicates whether the capture currently active. + /// + public bool TrackingActive { get; internal set; } + + /// + /// Profiling frequency; effectively: measure every Nth command. + /// + public long SampleRatio { get; internal set; } + + /// + /// The total CPU measured for all commands in all slots. + /// + public TimeSpan TotalCpuTime { get; internal set; } + + /// + /// The total network usage measured for all commands in all slots. + /// + public long TotalNetworkBytes { get; internal set; } + + internal long CollectionStartTimeUnixMilliseconds { get; set; } + + /// + /// The start time of the capture. + /// + public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); + + internal long CollectionDurationMilliseconds { get; set; } + + /// + /// The duration of the capture. + /// + public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); + + internal long TotalCpuTimeUserMilliseconds { get; set; } + + /// + /// The total user CPU time measured. + /// + public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); + + internal long TotalCpuTimeSystemMilliseconds { get; set; } + + /// + /// The total system CPU measured. + /// + public TimeSpan TotalCpuTimeSystem => TimeSpan.FromMilliseconds(TotalCpuTimeSystemMilliseconds); + + /// + /// The total network data measured. + /// + public long TotalNetworkBytes2 { get; internal set; } // total-net-bytes vs net-bytes-all-commands-all-slots + + // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking + // for a particular key (lookup), but rather: is likely to want to list them for display; this way, + // we'll preserve the server's display order. + + /// + /// Hot keys, as measured by CPU activity. + /// + public MetricKeyCpu[] CpuByKey { get; internal set; } = []; + + /// + /// Hot keys, as measured by network activity. + /// + public MetricKeyBytes[] NetworkBytesByKey { get; internal set; } = []; + + private const long TicksPerMicroSeconds = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer + + /// + /// A hot key, as measured by CPU activity. + /// + /// The key observed. + /// The time taken, in microseconds. + public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) + { + private readonly RedisKey _key = key; + + /// + /// The key observed. + /// + public RedisKey Key => _key; + + /// + /// The time taken, in microseconds. + /// + public long MicroSeconds => microSeconds; + + /// + /// The time taken. + /// + public TimeSpan Duration => TimeSpan.FromTicks(microSeconds / TicksPerMicroSeconds); + } + + /// + /// A hot key, as measured by network activity. + /// + /// The key observed. + /// The network activity, in bytes. + public readonly struct MetricKeyBytes(in RedisKey key, long bytes) + { + private readonly RedisKey _key = key; + + /// + /// The key observed. + /// + public RedisKey Key => _key; + + /// + /// The network activity, in bytes. + /// + public long Bytes => bytes; + } +} + +internal partial class RedisServer +{ + public void HotKeysStart( + HotKeysMetrics metrics = (HotKeysMetrics)~0, + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task HotKeysStartAsync( + HotKeysMetrics metrics = (HotKeysMetrics)~0, + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public void HotKeysStop(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + + public Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + + public void HotKeysReset(CommandFlags flags = CommandFlags.None) + => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); + + public Task HotKeysResetAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); + + public HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } + + public Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None) + { + throw new NotImplementedException(); + } +} diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 24a63d577..8e4178fc9 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Net; using System.Threading.Tasks; -using StackExchange.Redis; namespace StackExchange.Redis { @@ -805,49 +804,6 @@ public partial interface IServer : IRedis /// Task[][]> SentinelSentinelsAsync(string serviceName, CommandFlags flags = CommandFlags.None); - -/* - - [COUNT k] - [DURATION duration] - [SAMPLE ratio] - [SLOTS count slot…] - */ - /// - /// Start a new HOTKEYS profiling session. - /// - /// The metrics to record during this capture (defaults to "all"). - /// The total number of operations to profile. - /// The duration of this profiling session. - /// Profiling frequency; effectively: measure every Nth command. - /// The key-slots to record during this capture (defaults to "all"). - /// The command flags to use. - void HotKeysStart( - HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default - long count = -1, - TimeSpan duration = default, - long sampleRatio = 1, - short[]? slots = null, - CommandFlags flags = CommandFlags.None); - - /// - /// Stop the current HOTKEYS capture, if any. - /// - /// The command flags to use. - void HotKeysStop(CommandFlags flags = CommandFlags.None); - - /// - /// Discard the last HOTKEYS capture data, if any. - /// - /// The command flags to use. - void HotKeysReset(CommandFlags flags = CommandFlags.None); - - /// - /// Fetch the most recent HOTKEYS profiling data. - /// - /// The command flags to use. - /// The data captured during HOTKEYS profiling. - HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None); } internal static class IServerExtensions @@ -860,144 +816,3 @@ internal static class IServerExtensions internal static void SimulateConnectionFailure(this IServer server, SimulatedFailureType failureType) => (server as RedisServer)?.SimulateConnectionFailure(failureType); } } - -/// -/// Metrics to record during HOTKEYS profiling. -/// -[Flags] -public enum HotKeysMetrics -{ - /// - /// Capture CPU time. - /// - Cpu = 1 << 0, - - /// - /// Capture network bytes. - /// - Network = 1 << 1, -} - -/// -/// Captured data from HOTKEYS profiling. -/// -public sealed class HotKeysResult -{ - internal HotKeysResult() - { - } - - /// - /// Is the capture currently active? - /// - public bool TrackingActive { get; internal set; } - - /// - /// Profiling frequency; effectively: measure every Nth command. - /// - public long SampleRatio { get; internal set; } - - /// - /// The total CPU measured for all commands in all slots. - /// - public TimeSpan TotalCpuTime { get; internal set; } - - /// - /// The total network usage measured for all commands in all slots. - /// - public long TotalNetworkBytes { get; internal set; } - - internal long CollectionStartTimeUnixMilliseconds { get; set; } - - /// - /// The start time of the capture. - /// - public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); - - internal long CollectionDurationMilliseconds { get; set; } - - /// - /// The duration of the capture. - /// - public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); - - internal long TotalCpuTimeUserMilliseconds { get; set; } - - /// - /// The total user CPU time measured. - /// - public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); - - internal long TotalCpuTimeSystemMilliseconds { get; set; } - - /// - /// The total system CPU measured. - /// - public TimeSpan TotalCpuTimeSystem => TimeSpan.FromMilliseconds(TotalCpuTimeSystemMilliseconds); - - /// - /// The total network data measured. - /// - public long TotalNetworkBytes2 { get; internal set; } // total-net-bytes vs net-bytes-all-commands-all-slots - - // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking - // for a particular key (lookup), but rather: is likely to want to list them for display; this way, - // we'll preserve the server's display order. - - /// - /// Hot keys, as measured by CPU activity. - /// - public MetricKeyCpu[] CpuByKey { get; internal set; } = []; - - /// - /// Hot keys, as measured by network activity. - /// - public MetricKeyBytes[] NetworkBytesByKey { get; internal set; } = []; - - private const long TicksPerMicroSeconds = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer - - /// - /// A hot key, as measured by CPU activity. - /// - /// The key observed. - /// The time taken, in microseconds. - public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) - { - private readonly RedisKey _key = key; - - /// - /// The key observed. - /// - public RedisKey Key => _key; - - /// - /// The time taken, in microseconds. - /// - public long MicroSeconds => microSeconds; - - /// - /// The time taken. - /// - public TimeSpan Duration => TimeSpan.FromTicks(microSeconds / TicksPerMicroSeconds); - } - - /// - /// A hot key, as measured by network activity. - /// - /// The key observed. - /// The network activity, in bytes. - public readonly struct MetricKeyBytes(in RedisKey key, long bytes) - { - private readonly RedisKey _key = key; - - /// - /// The key observed. - /// - public RedisKey Key => _key; - - /// - /// The network activity, in bytes. - /// - public long Bytes => bytes; - } -} diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 37472fd4c..faf25ba44 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -182,6 +182,7 @@ public bool IsAdmin case RedisCommand.DEBUG: case RedisCommand.FLUSHALL: case RedisCommand.FLUSHDB: + case RedisCommand.HOTKEYS: case RedisCommand.INFO: case RedisCommand.KEYS: case RedisCommand.MONITOR: @@ -553,6 +554,7 @@ internal static bool RequiresDatabase(RedisCommand command) case RedisCommand.ECHO: case RedisCommand.FLUSHALL: case RedisCommand.HELLO: + case RedisCommand.HOTKEYS: case RedisCommand.INFO: case RedisCommand.LASTSAVE: case RedisCommand.LATENCY: diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 9d94db345..1768667e1 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,32 +1,35 @@ #nullable enable -HotKeysMetrics -HotKeysMetrics.Cpu = 1 -> HotKeysMetrics -HotKeysMetrics.Network = 2 -> HotKeysMetrics -HotKeysMetrics.None = 0 -> HotKeysMetrics -HotKeysResult -HotKeysResult.CollectionDuration.get -> System.TimeSpan -HotKeysResult.CollectionStartTime.get -> System.DateTime -HotKeysResult.CpuByKey.get -> HotKeysResult.MetricKeyCpu[]! -HotKeysResult.MetricKeyBytes -HotKeysResult.MetricKeyBytes.Bytes.get -> long -HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey -HotKeysResult.MetricKeyBytes.MetricKeyBytes() -> void -HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void -HotKeysResult.MetricKeyCpu -HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan -HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey -HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void -HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void -HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long -HotKeysResult.NetworkBytesByKey.get -> HotKeysResult.MetricKeyBytes[]! -HotKeysResult.SampleRatio.get -> long -HotKeysResult.TotalCpuTime.get -> System.TimeSpan -HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan -HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan -HotKeysResult.TotalNetworkBytes.get -> long -HotKeysResult.TotalNetworkBytes2.get -> long -HotKeysResult.TrackingActive.get -> bool -StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> HotKeysResult! +StackExchange.Redis.HotKeysMetrics +StackExchange.Redis.HotKeysMetrics.Cpu = 1 -> StackExchange.Redis.HotKeysMetrics +StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics +StackExchange.Redis.HotKeysResult +StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan +StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime +StackExchange.Redis.HotKeysResult.CpuByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyCpu[]! +StackExchange.Redis.HotKeysResult.MetricKeyBytes +StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long +StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey +StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes() -> void +StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void +StackExchange.Redis.HotKeysResult.MetricKeyCpu +StackExchange.Redis.HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan +StackExchange.Redis.HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey +StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void +StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void +StackExchange.Redis.HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long +StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyBytes[]! +StackExchange.Redis.HotKeysResult.SampleRatio.get -> long +StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan +StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan +StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan +StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long +StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long +StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool +StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult! +StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -StackExchange.Redis.IServer.HotKeysStart(HotKeysMetrics metrics = (HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 9a8c15613..efb8cd1cf 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -144,6 +144,7 @@ public static readonly RedisValue SETNAME = "SETNAME", SKIPME = "SKIPME", STATS = "STATS", + STOP = "STOP", STORE = "STORE", TYPE = "TYPE", USERNAME = "USERNAME", diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 3bc306c69..2d7e184ad 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis { - internal sealed class RedisServer : RedisBase, IServer + internal sealed partial class RedisServer : RedisBase, IServer { private readonly ServerEndPoint server; From cf4ebb0ab6689556cb8059c2c958a78fb07cd3e0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 18:13:45 +0000 Subject: [PATCH 03/27] untested stab at message impl --- .../HotKeys.ResultProcessor.cs | 56 +++++++++++++ src/StackExchange.Redis/HotKeys.Server.cs | 47 +++++++++++ .../HotKeys.StartMessage.cs | 79 +++++++++++++++++ src/StackExchange.Redis/HotKeys.cs | 84 ++++--------------- .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- .../StackExchange.Redis.csproj | 2 + 6 files changed, 200 insertions(+), 72 deletions(-) create mode 100644 src/StackExchange.Redis/HotKeys.ResultProcessor.cs create mode 100644 src/StackExchange.Redis/HotKeys.Server.cs create mode 100644 src/StackExchange.Redis/HotKeys.StartMessage.cs diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs new file mode 100644 index 000000000..5c40f2b3b --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HotKeysResult +{ + internal static readonly ResultProcessor Processor = new HotKeysResultProcessor(); + + private sealed class HotKeysResultProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + { + if (result.IsNull) + { + SetResult(message, null); + return true; + } + + if (result.Resp2TypeBulkString == ResultType.Array) + { + var hotKeys = new HotKeysResult(in result); + SetResult(message, hotKeys); + return true; + } + + return false; + } + } + + private HotKeysResult(in RawResult result) + { + var iter = result.GetItems().GetEnumerator(); + while (iter.MoveNext()) + { + ref readonly RawResult key = ref iter.Current; + if (iter.MoveNext()) + { + ref readonly RawResult value = ref iter.Current; + var hash = key.Payload.Hash64(); + switch (hash) + { + case tracking_active.Hash when tracking_active.Is(hash, value): + TrackingActive = value.GetBoolean(); + break; + } + } + } + } + +#pragma warning disable SA1134, SA1300 + // ReSharper disable InconsistentNaming + [FastHash] internal static partial class tracking_active { } + // ReSharper restore InconsistentNaming +#pragma warning restore SA1134, SA1300 +} diff --git a/src/StackExchange.Redis/HotKeys.Server.cs b/src/StackExchange.Redis/HotKeys.Server.cs new file mode 100644 index 000000000..8295d1702 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.Server.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal partial class RedisServer +{ + public void HotKeysStart( + HotKeysMetrics metrics = (HotKeysMetrics)~0, + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None) + => ExecuteSync( + new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), + ResultProcessor.DemandOK); + + public Task HotKeysStartAsync( + HotKeysMetrics metrics = (HotKeysMetrics)~0, + long count = -1, + TimeSpan duration = default, + long sampleRatio = 1, + short[]? slots = null, + CommandFlags flags = CommandFlags.None) + => ExecuteAsync( + new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), + ResultProcessor.DemandOK); + + public void HotKeysStop(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + + public Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + + public void HotKeysReset(CommandFlags flags = CommandFlags.None) + => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); + + public Task HotKeysResetAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); + + public HotKeysResult? HotKeysGet(CommandFlags flags = CommandFlags.None) + => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.GET), HotKeysResult.Processor, server); + + public Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.GET), HotKeysResult.Processor, server); +} diff --git a/src/StackExchange.Redis/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs new file mode 100644 index 000000000..c862663d7 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal partial class RedisServer +{ + internal sealed class HotKeysStartMessage( + CommandFlags flags, + HotKeysMetrics metrics, + long count, + TimeSpan duration, + long sampleRatio, + short[]? slots) : Message(-1, flags, RedisCommand.HOTKEYS) + { + protected override void WriteImpl(PhysicalConnection physical) + { + /* + HOTKEYS + + [COUNT k] + [DURATION duration] + [SAMPLE ratio] + [SLOTS count slot…] + */ + physical.WriteHeader(Command, ArgCount); + physical.WriteBulkString("METRICS"u8); + var metricCount = 0; + if ((metrics & HotKeysMetrics.Cpu) != 0) metricCount++; + if ((metrics & HotKeysMetrics.Network) != 0) metricCount++; + physical.WriteBulkString(metricCount); + if ((metrics & HotKeysMetrics.Cpu) != 0) physical.WriteBulkString("CPU"u8); + if ((metrics & HotKeysMetrics.Network) != 0) physical.WriteBulkString("NET"u8); + + if (count != 0) + { + physical.WriteBulkString("COUNT"u8); + physical.WriteBulkString(count); + } + + if (duration != TimeSpan.Zero) + { + physical.WriteBulkString("DURATION"u8); + physical.WriteBulkString(Math.Ceiling(duration.TotalSeconds)); + } + + if (sampleRatio != 0) + { + physical.WriteBulkString("SAMPLE"u8); + physical.WriteBulkString(sampleRatio); + } + + if (slots is { Length: > 0 }) + { + physical.WriteBulkString("SLOTS"u8); + physical.WriteBulkString(slots.Length); + foreach (var slot in slots) + { + physical.WriteBulkString(slot); + } + } + } + + public override int ArgCount + { + get + { + int argCount = 2; + if ((metrics & HotKeysMetrics.Cpu) != 0) argCount++; + if ((metrics & HotKeysMetrics.Network) != 0) argCount++; + if (count != 0) argCount += 2; + if (duration != TimeSpan.Zero) argCount += 2; + if (sampleRatio != 0) argCount += 2; + if (slots is { Length: > 0 }) argCount += 2 + slots.Length; + return argCount; + } + } + } +} diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index b66588a93..10fd1dbbc 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -5,15 +5,6 @@ namespace StackExchange.Redis; public partial interface IServer { - /* - HOTKEYS - - [COUNT k] - [DURATION duration] - [SAMPLE ratio] - [SLOTS count slot…] - */ - /// /// Start a new HOTKEYS profiling session. /// @@ -77,14 +68,14 @@ Task HotKeysStartAsync( /// /// The command flags to use. /// The data captured during HOTKEYS profiling. - HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None); + HotKeysResult? HotKeysGet(CommandFlags flags = CommandFlags.None); /// /// Fetch the most recent HOTKEYS profiling data. /// /// The command flags to use. /// The data captured during HOTKEYS profiling. - Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None); + Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None); } /// @@ -107,7 +98,7 @@ public enum HotKeysMetrics /// /// Captured data from HOTKEYS profiling. /// -public sealed class HotKeysResult +public sealed partial class HotKeysResult { internal HotKeysResult() { @@ -116,45 +107,45 @@ internal HotKeysResult() /// /// Indicates whether the capture currently active. /// - public bool TrackingActive { get; internal set; } + public bool TrackingActive { get; } /// /// Profiling frequency; effectively: measure every Nth command. /// - public long SampleRatio { get; internal set; } + public long SampleRatio { get; } /// /// The total CPU measured for all commands in all slots. /// - public TimeSpan TotalCpuTime { get; internal set; } + public TimeSpan TotalCpuTime { get; } /// /// The total network usage measured for all commands in all slots. /// - public long TotalNetworkBytes { get; internal set; } + public long TotalNetworkBytes { get; } - internal long CollectionStartTimeUnixMilliseconds { get; set; } + internal long CollectionStartTimeUnixMilliseconds { get; } /// /// The start time of the capture. /// public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); - internal long CollectionDurationMilliseconds { get; set; } + internal long CollectionDurationMilliseconds { get; } /// /// The duration of the capture. /// public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); - internal long TotalCpuTimeUserMilliseconds { get; set; } + internal long TotalCpuTimeUserMilliseconds { get; } /// /// The total user CPU time measured. /// public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); - internal long TotalCpuTimeSystemMilliseconds { get; set; } + internal long TotalCpuTimeSystemMilliseconds { get; } /// /// The total system CPU measured. @@ -164,7 +155,7 @@ internal HotKeysResult() /// /// The total network data measured. /// - public long TotalNetworkBytes2 { get; internal set; } // total-net-bytes vs net-bytes-all-commands-all-slots + public long TotalNetworkBytes2 { get; } // total-net-bytes vs net-bytes-all-commands-all-slots // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking // for a particular key (lookup), but rather: is likely to want to list them for display; this way, @@ -173,12 +164,12 @@ internal HotKeysResult() /// /// Hot keys, as measured by CPU activity. /// - public MetricKeyCpu[] CpuByKey { get; internal set; } = []; + public MetricKeyCpu[] CpuByKey { get; } = []; /// /// Hot keys, as measured by network activity. /// - public MetricKeyBytes[] NetworkBytesByKey { get; internal set; } = []; + public MetricKeyBytes[] NetworkBytesByKey { get; } = []; private const long TicksPerMicroSeconds = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer @@ -227,50 +218,3 @@ public readonly struct MetricKeyBytes(in RedisKey key, long bytes) public long Bytes => bytes; } } - -internal partial class RedisServer -{ - public void HotKeysStart( - HotKeysMetrics metrics = (HotKeysMetrics)~0, - long count = -1, - TimeSpan duration = default, - long sampleRatio = 1, - short[]? slots = null, - CommandFlags flags = CommandFlags.None) - { - throw new NotImplementedException(); - } - - public Task HotKeysStartAsync( - HotKeysMetrics metrics = (HotKeysMetrics)~0, - long count = -1, - TimeSpan duration = default, - long sampleRatio = 1, - short[]? slots = null, - CommandFlags flags = CommandFlags.None) - { - throw new NotImplementedException(); - } - - public void HotKeysStop(CommandFlags flags = CommandFlags.None) - => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); - - public Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None) - => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); - - public void HotKeysReset(CommandFlags flags = CommandFlags.None) - => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); - - public Task HotKeysResetAsync(CommandFlags flags = CommandFlags.None) - => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); - - public HotKeysResult HotKeysGet(CommandFlags flags = CommandFlags.None) - { - throw new NotImplementedException(); - } - - public Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None) - { - throw new NotImplementedException(); - } -} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1768667e1..5b6bc6468 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -25,8 +25,8 @@ StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool -StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult! -StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? +StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 84e495f1a..2c2e7702a 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -35,6 +35,8 @@ + + From db1c334d0ee333c622c00e88d0a5ba22ab46603f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 18:55:19 +0000 Subject: [PATCH 04/27] untested result processor --- .../HotKeys.ResultProcessor.cs | 93 +++++++++++++++++++ src/StackExchange.Redis/HotKeys.Server.cs | 8 +- src/StackExchange.Redis/HotKeys.cs | 13 ++- .../PublicAPI/PublicAPI.Unshipped.txt | 5 +- 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 5c40f2b3b..e65121dbd 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -43,6 +43,87 @@ private HotKeysResult(in RawResult result) case tracking_active.Hash when tracking_active.Is(hash, value): TrackingActive = value.GetBoolean(); break; + case sample_ratio.Hash when sample_ratio.Is(hash, value) && value.TryGetInt64(out var i64): + SampleRatio = i64; + break; + case selected_slots.Hash when selected_slots.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + var len = value.ItemsCount; + if (len == 0) continue; + + var items = value.GetItems().GetEnumerator(); + var slots = new SlotRange[len]; + for (int i = 0; i < len && items.MoveNext(); i++) + { + ref readonly RawResult pair = ref items.Current; + if (pair.Resp2TypeArray is ResultType.Array + && pair.ItemsCount == 2 + && pair[0].TryGetInt64(out var from) + && pair[1].TryGetInt64(out var to)) + { + slots[i] = new((int)from, (int)to); + } + } + SelectedSlots = slots; + break; + case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, value) && value.TryGetInt64(out var i64): + TotalCpuTimeMilliseconds = i64; + break; + case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, value) && value.TryGetInt64(out var i64): + TotalNetworkBytes = i64; + break; + case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, value) && value.TryGetInt64(out var i64): + CollectionStartTimeUnixMilliseconds = i64; + break; + case collection_duration_ms.Hash when collection_duration_ms.Is(hash, value) && value.TryGetInt64(out var i64): + CollectionDurationMilliseconds = i64; + break; + case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, value) && value.TryGetInt64(out var i64): + TotalCpuTimeSystemMilliseconds = i64; + break; + case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, value) && value.TryGetInt64(out var i64): + TotalCpuTimeUserMilliseconds = i64; + break; + case total_net_bytes.Hash when total_net_bytes.Is(hash, value) && value.TryGetInt64(out var i64): + TotalNetworkBytes2 = i64; + break; + case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + len = value.ItemsCount; + if (len == 0) continue; + + var cpuTime = new MetricKeyCpu[len]; + items = value.GetItems().GetEnumerator(); + for (int i = 0; i < len && items.MoveNext(); i++) + { + ref readonly RawResult pair = ref items.Current; + if (pair.Resp2TypeArray is ResultType.Array + && pair.ItemsCount == 2 + && pair[1].TryGetInt64(out var cpu)) + { + cpuTime[i] = new(pair[0].AsRedisKey(), cpu); + } + } + + CpuByKey = cpuTime; + break; + case by_net_bytes.Hash when by_net_bytes.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + len = value.ItemsCount; + if (len == 0) continue; + + var netBytes = new MetricKeyBytes[len]; + items = value.GetItems().GetEnumerator(); + for (int i = 0; i < len && items.MoveNext(); i++) + { + ref readonly RawResult pair = ref items.Current; + if (pair.Resp2TypeArray is ResultType.Array + && pair.ItemsCount == 2 + && pair[1].TryGetInt64(out var bytes)) + { + netBytes[i] = new(pair[0].AsRedisKey(), bytes); + } + } + + NetworkBytesByKey = netBytes; + break; } } } @@ -51,6 +132,18 @@ private HotKeysResult(in RawResult result) #pragma warning disable SA1134, SA1300 // ReSharper disable InconsistentNaming [FastHash] internal static partial class tracking_active { } + [FastHash] internal static partial class sample_ratio { } + [FastHash] internal static partial class selected_slots { } + [FastHash] internal static partial class all_commands_all_slots_us { } + [FastHash] internal static partial class net_bytes_all_commands_all_slots { } + [FastHash] internal static partial class collection_start_time_unix_ms { } + [FastHash] internal static partial class collection_duration_ms { } + [FastHash] internal static partial class total_cpu_time_user_ms { } + [FastHash] internal static partial class total_cpu_time_sys_ms { } + [FastHash] internal static partial class total_net_bytes { } + [FastHash] internal static partial class by_cpu_time_us { } + [FastHash] internal static partial class by_net_bytes { } + // ReSharper restore InconsistentNaming #pragma warning restore SA1134, SA1300 } diff --git a/src/StackExchange.Redis/HotKeys.Server.cs b/src/StackExchange.Redis/HotKeys.Server.cs index 8295d1702..c7ea7bf62 100644 --- a/src/StackExchange.Redis/HotKeys.Server.cs +++ b/src/StackExchange.Redis/HotKeys.Server.cs @@ -27,11 +27,11 @@ public Task HotKeysStartAsync( new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), ResultProcessor.DemandOK); - public void HotKeysStop(CommandFlags flags = CommandFlags.None) - => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + public bool HotKeysStop(CommandFlags flags = CommandFlags.None) + => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.Boolean, server); - public Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None) - => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.DemandOK, server); + public Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None) + => ExecuteAsync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.STOP), ResultProcessor.Boolean, server); public void HotKeysReset(CommandFlags flags = CommandFlags.None) => ExecuteSync(Message.Create(-1, flags, RedisCommand.HOTKEYS, RedisLiterals.RESET), ResultProcessor.DemandOK, server); diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 10fd1dbbc..ca285d062 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -43,13 +43,13 @@ Task HotKeysStartAsync( /// Stop the current HOTKEYS capture, if any. /// /// The command flags to use. - void HotKeysStop(CommandFlags flags = CommandFlags.None); + bool HotKeysStop(CommandFlags flags = CommandFlags.None); /// /// Stop the current HOTKEYS capture, if any. /// /// The command flags to use. - Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None); + Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None); /// /// Discard the last HOTKEYS capture data, if any. @@ -114,10 +114,17 @@ internal HotKeysResult() /// public long SampleRatio { get; } + /// + /// The key slots active for this profiling session. + /// + public SlotRange[] SelectedSlots { get; } = []; + /// /// The total CPU measured for all commands in all slots. /// - public TimeSpan TotalCpuTime { get; } + public TimeSpan TotalCpuTime => TimeSpan.FromMilliseconds(TotalCpuTimeMilliseconds); + + private long TotalCpuTimeMilliseconds { get; } /// /// The total network usage measured for all commands in all slots. diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 5b6bc6468..6cd7ecbe0 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -19,6 +19,7 @@ StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Red StackExchange.Redis.HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyBytes[]! StackExchange.Redis.HotKeysResult.SampleRatio.get -> long +StackExchange.Redis.HotKeysResult.SelectedSlots.get -> StackExchange.Redis.SlotRange[]! StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan @@ -31,5 +32,5 @@ StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! From 8696b9d30587beaebe4d2be7e4bac56ea3cc1d85 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 20:10:28 +0000 Subject: [PATCH 05/27] basic integration tests --- .../HotKeys.ResultProcessor.cs | 37 ++++---- src/StackExchange.Redis/HotKeys.Server.cs | 4 +- .../HotKeys.StartMessage.cs | 5 +- src/StackExchange.Redis/HotKeys.cs | 4 +- .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- .../StackExchange.Redis.Tests/HotKeysTests.cs | 92 +++++++++++++++++++ 6 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/HotKeysTests.cs diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index e65121dbd..e1179aaf6 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -17,11 +17,16 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } - if (result.Resp2TypeBulkString == ResultType.Array) + // an array with a single element that *is* an array/map that is the results + if (result is { Resp2TypeArray: ResultType.Array, ItemsCount: 1 }) { - var hotKeys = new HotKeysResult(in result); - SetResult(message, hotKeys); - return true; + ref readonly RawResult inner = ref result[0]; + if (inner is { Resp2TypeArray: ResultType.Array, IsNull: false }) + { + var hotKeys = new HotKeysResult(in inner); + SetResult(message, hotKeys); + return true; + } } return false; @@ -40,13 +45,13 @@ private HotKeysResult(in RawResult result) var hash = key.Payload.Hash64(); switch (hash) { - case tracking_active.Hash when tracking_active.Is(hash, value): + case tracking_active.Hash when tracking_active.Is(hash, key): TrackingActive = value.GetBoolean(); break; - case sample_ratio.Hash when sample_ratio.Is(hash, value) && value.TryGetInt64(out var i64): + case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out var i64): SampleRatio = i64; break; - case selected_slots.Hash when selected_slots.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: var len = value.ItemsCount; if (len == 0) continue; @@ -65,28 +70,28 @@ private HotKeysResult(in RawResult result) } SelectedSlots = slots; break; - case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, value) && value.TryGetInt64(out var i64): + case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): TotalCpuTimeMilliseconds = i64; break; - case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, value) && value.TryGetInt64(out var i64): + case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): TotalNetworkBytes = i64; break; - case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, value) && value.TryGetInt64(out var i64): + case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): CollectionStartTimeUnixMilliseconds = i64; break; - case collection_duration_ms.Hash when collection_duration_ms.Is(hash, value) && value.TryGetInt64(out var i64): + case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out var i64): CollectionDurationMilliseconds = i64; break; - case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, value) && value.TryGetInt64(out var i64): + case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out var i64): TotalCpuTimeSystemMilliseconds = i64; break; - case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, value) && value.TryGetInt64(out var i64): + case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out var i64): TotalCpuTimeUserMilliseconds = i64; break; - case total_net_bytes.Hash when total_net_bytes.Is(hash, value) && value.TryGetInt64(out var i64): + case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out var i64): TotalNetworkBytes2 = i64; break; - case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: len = value.ItemsCount; if (len == 0) continue; @@ -105,7 +110,7 @@ private HotKeysResult(in RawResult result) CpuByKey = cpuTime; break; - case by_net_bytes.Hash when by_net_bytes.Is(hash, value) & value.Resp2TypeArray is ResultType.Array: + case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: len = value.ItemsCount; if (len == 0) continue; diff --git a/src/StackExchange.Redis/HotKeys.Server.cs b/src/StackExchange.Redis/HotKeys.Server.cs index c7ea7bf62..529de18c1 100644 --- a/src/StackExchange.Redis/HotKeys.Server.cs +++ b/src/StackExchange.Redis/HotKeys.Server.cs @@ -7,7 +7,7 @@ internal partial class RedisServer { public void HotKeysStart( HotKeysMetrics metrics = (HotKeysMetrics)~0, - long count = -1, + long count = 0, TimeSpan duration = default, long sampleRatio = 1, short[]? slots = null, @@ -18,7 +18,7 @@ public void HotKeysStart( public Task HotKeysStartAsync( HotKeysMetrics metrics = (HotKeysMetrics)~0, - long count = -1, + long count = 0, TimeSpan duration = default, long sampleRatio = 1, short[]? slots = null, diff --git a/src/StackExchange.Redis/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs index c862663d7..ec26745b0 100644 --- a/src/StackExchange.Redis/HotKeys.StartMessage.cs +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -16,7 +16,7 @@ internal sealed class HotKeysStartMessage( protected override void WriteImpl(PhysicalConnection physical) { /* - HOTKEYS + HOTKEYS START [COUNT k] [DURATION duration] @@ -24,6 +24,7 @@ [SAMPLE ratio] [SLOTS count slot…] */ physical.WriteHeader(Command, ArgCount); + physical.WriteBulkString("START"u8); physical.WriteBulkString("METRICS"u8); var metricCount = 0; if ((metrics & HotKeysMetrics.Cpu) != 0) metricCount++; @@ -65,7 +66,7 @@ public override int ArgCount { get { - int argCount = 2; + int argCount = 3; if ((metrics & HotKeysMetrics.Cpu) != 0) argCount++; if ((metrics & HotKeysMetrics.Network) != 0) argCount++; if (count != 0) argCount += 2; diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index ca285d062..594a685c4 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -16,7 +16,7 @@ public partial interface IServer /// The command flags to use. void HotKeysStart( HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default - long count = -1, + long count = 0, TimeSpan duration = default, long sampleRatio = 1, short[]? slots = null, @@ -33,7 +33,7 @@ void HotKeysStart( /// The command flags to use. Task HotKeysStartAsync( HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default - long count = -1, + long count = 0, TimeSpan duration = default, long sampleRatio = 1, short[]? slots = null, diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 6cd7ecbe0..2f8b53cc9 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -30,7 +30,7 @@ StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = -1, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs new file mode 100644 index 000000000..1727a3902 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -0,0 +1,92 @@ +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +[Collection(NonParallelCollection.Name)] +public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + private IInternalConnectionMultiplexer GetServer(out IServer server) + => GetServer(RedisKey.Null, out server); + + private IInternalConnectionMultiplexer GetServer(in RedisKey key, out IServer server) + { + var muxer = Create(require: RedisFeatures.v8_4_0_rc1); // TODO: 8.6 + server = key.IsNull ? muxer.GetServer(muxer.GetEndPoints()[0]) : muxer.GetServer(key); + server.HotKeysStop(CommandFlags.FireAndForget); + server.HotKeysReset(CommandFlags.FireAndForget); + return muxer; + } + + [Fact] + public void GetWhenEmptyIsNull() + { + using var muxer = GetServer(out var server); + Assert.Null(server.HotKeysGet()); + } + + [Fact] + public async Task GetWhenEmptyIsNullAsync() + { + await using var muxer = GetServer(out var server); + Assert.Null(await server.HotKeysGetAsync()); + } + + [Fact] + public void StopWhenNotRunningIsFalse() + { + using var muxer = GetServer(out var server); + Assert.False(server.HotKeysStop()); + } + + [Fact] + public async Task StopWhenNotRunningIsFalseAsync() + { + await using var muxer = GetServer(out var server); + Assert.False(await server.HotKeysStopAsync()); + } + + [Fact] + public void CanStartStopReset() + { + RedisKey key = Me(); + using var muxer = GetServer(key, out var server); + server.HotKeysStart(); + muxer.GetDatabase().StringSet(key, "value1"); + var result = server.HotKeysGet(); + Assert.NotNull(result); + Assert.True(result.TrackingActive); + + Assert.True(server.HotKeysStop()); + result = server.HotKeysGet(); + Assert.NotNull(result); + Assert.False(result.TrackingActive); + Assert.NotNull(result); + + server.HotKeysReset(); + result = server.HotKeysGet(); + Assert.Null(result); + } + + [Fact] + public async Task CanStartStopResetAsync() + { + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + await server.HotKeysStartAsync(); + await muxer.GetDatabase().StringSetAsync(key, "value1"); + var result = await server.HotKeysGetAsync(); + Assert.NotNull(result); + Assert.True(result.TrackingActive); + + Assert.True(await server.HotKeysStopAsync()); + result = await server.HotKeysGetAsync(); + Assert.NotNull(result); + Assert.False(result.TrackingActive); + Assert.NotNull(result); + + await server.HotKeysResetAsync(); + result = await server.HotKeysGetAsync(); + Assert.Null(result); + } +} From 3c7aeb8d27a2e02316b07a2fd6b00ed8bee44018 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 20:58:29 +0000 Subject: [PATCH 06/27] more integration tests --- .../HotKeys.ResultProcessor.cs | 25 ++++------ src/StackExchange.Redis/HotKeys.cs | 32 +++++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 6 +++ .../StackExchange.Redis.Tests/HotKeysTests.cs | 50 ++++++++++++++++--- 4 files changed, 83 insertions(+), 30 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index e1179aaf6..e588fae8f 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -1,7 +1,4 @@ -using System; -using System.Threading.Tasks; - -namespace StackExchange.Redis; +namespace StackExchange.Redis; public sealed partial class HotKeysResult { @@ -92,38 +89,34 @@ private HotKeysResult(in RawResult result) TotalNetworkBytes2 = i64; break; case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: - len = value.ItemsCount; + len = value.ItemsCount / 2; if (len == 0) continue; var cpuTime = new MetricKeyCpu[len]; items = value.GetItems().GetEnumerator(); for (int i = 0; i < len && items.MoveNext(); i++) { - ref readonly RawResult pair = ref items.Current; - if (pair.Resp2TypeArray is ResultType.Array - && pair.ItemsCount == 2 - && pair[1].TryGetInt64(out var cpu)) + var metricKey = items.Current.AsRedisKey(); + if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) { - cpuTime[i] = new(pair[0].AsRedisKey(), cpu); + cpuTime[i] = new(metricKey, metricValue); } } CpuByKey = cpuTime; break; case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: - len = value.ItemsCount; + len = value.ItemsCount / 2; if (len == 0) continue; var netBytes = new MetricKeyBytes[len]; items = value.GetItems().GetEnumerator(); for (int i = 0; i < len && items.MoveNext(); i++) { - ref readonly RawResult pair = ref items.Current; - if (pair.Resp2TypeArray is ResultType.Array - && pair.ItemsCount == 2 - && pair[1].TryGetInt64(out var bytes)) + var metricKey = items.Current.AsRedisKey(); + if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) { - netBytes[i] = new(pair[0].AsRedisKey(), bytes); + netBytes[i] = new(metricKey, metricValue); } } diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 594a685c4..66ce2ba54 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -100,10 +100,6 @@ public enum HotKeysMetrics /// public sealed partial class HotKeysResult { - internal HotKeysResult() - { - } - /// /// Indicates whether the capture currently active. /// @@ -131,28 +127,28 @@ internal HotKeysResult() /// public long TotalNetworkBytes { get; } - internal long CollectionStartTimeUnixMilliseconds { get; } + private long CollectionStartTimeUnixMilliseconds { get; } /// /// The start time of the capture. /// public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); - internal long CollectionDurationMilliseconds { get; } + private long CollectionDurationMilliseconds { get; } /// /// The duration of the capture. /// public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); - internal long TotalCpuTimeUserMilliseconds { get; } + private long TotalCpuTimeUserMilliseconds { get; } /// /// The total user CPU time measured. /// public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); - internal long TotalCpuTimeSystemMilliseconds { get; } + private long TotalCpuTimeSystemMilliseconds { get; } /// /// The total system CPU measured. @@ -203,6 +199,16 @@ public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) /// The time taken. /// public TimeSpan Duration => TimeSpan.FromTicks(microSeconds / TicksPerMicroSeconds); + + /// + public override string ToString() => $"{_key}: {Duration}"; + + /// + public override int GetHashCode() => _key.GetHashCode() ^ microSeconds.GetHashCode(); + + /// + public override bool Equals(object? obj) + => obj is MetricKeyCpu other && _key.Equals(other.Key) && MicroSeconds == other.MicroSeconds; } /// @@ -223,5 +229,15 @@ public readonly struct MetricKeyBytes(in RedisKey key, long bytes) /// The network activity, in bytes. /// public long Bytes => bytes; + + /// + public override string ToString() => $"{_key}: {bytes}B"; + + /// + public override int GetHashCode() => _key.GetHashCode() ^ bytes.GetHashCode(); + + /// + public override bool Equals(object? obj) + => obj is MetricKeyBytes other && _key.Equals(other.Key) && Bytes == other.Bytes; } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2f8b53cc9..8b02f2f9e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +override StackExchange.Redis.HotKeysResult.MetricKeyBytes.Equals(object? obj) -> bool +override StackExchange.Redis.HotKeysResult.MetricKeyBytes.GetHashCode() -> int +override StackExchange.Redis.HotKeysResult.MetricKeyBytes.ToString() -> string! +override StackExchange.Redis.HotKeysResult.MetricKeyCpu.Equals(object? obj) -> bool +override StackExchange.Redis.HotKeysResult.MetricKeyCpu.GetHashCode() -> int +override StackExchange.Redis.HotKeysResult.MetricKeyCpu.ToString() -> string! StackExchange.Redis.HotKeysMetrics StackExchange.Redis.HotKeysMetrics.Cpu = 1 -> StackExchange.Redis.HotKeysMetrics StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index 1727a3902..9d298cd39 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Xunit; namespace StackExchange.Redis.Tests; @@ -7,7 +8,7 @@ namespace StackExchange.Redis.Tests; public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { private IInternalConnectionMultiplexer GetServer(out IServer server) - => GetServer(RedisKey.Null, out server); + => GetServer(RedisKey.Null, out server); private IInternalConnectionMultiplexer GetServer(in RedisKey key, out IServer server) { @@ -52,38 +53,75 @@ public void CanStartStopReset() RedisKey key = Me(); using var muxer = GetServer(key, out var server); server.HotKeysStart(); - muxer.GetDatabase().StringSet(key, "value1"); + var db = muxer.GetDatabase(); + db.KeyDelete(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + db.StringIncrement(key, flags: CommandFlags.FireAndForget); + } + var result = server.HotKeysGet(); Assert.NotNull(result); Assert.True(result.TrackingActive); + CheckSimpleWithKey(key, result); Assert.True(server.HotKeysStop()); result = server.HotKeysGet(); Assert.NotNull(result); Assert.False(result.TrackingActive); - Assert.NotNull(result); + CheckSimpleWithKey(key, result); server.HotKeysReset(); result = server.HotKeysGet(); Assert.Null(result); } + private static void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys) + { + Assert.True(hotKeys.CollectionDuration > TimeSpan.Zero); + Assert.True(hotKeys.CollectionStartTime > new DateTime(2026, 2, 1)); + var cpu = Assert.Single(hotKeys.CpuByKey); + Assert.Equal(key, cpu.Key); + Assert.True(cpu.Duration > TimeSpan.Zero); + var net = Assert.Single(hotKeys.NetworkBytesByKey); + Assert.Equal(key, net.Key); + Assert.True(net.Bytes > 0); + + Assert.Equal(1, hotKeys.SampleRatio); + var slots = Assert.Single(hotKeys.SelectedSlots); + Assert.Equal(0, slots.From); + Assert.Equal(16383, slots.To); + + Assert.True(hotKeys.TotalCpuTime > TimeSpan.Zero); + Assert.True(hotKeys.TotalCpuTimeSystem >= TimeSpan.Zero); + Assert.True(hotKeys.TotalCpuTimeUser >= TimeSpan.Zero); + Assert.True(hotKeys.TotalNetworkBytes > 0); + Assert.True(hotKeys.TotalNetworkBytes2 > 0); + } + [Fact] public async Task CanStartStopResetAsync() { RedisKey key = Me(); await using var muxer = GetServer(key, out var server); await server.HotKeysStartAsync(); - await muxer.GetDatabase().StringSetAsync(key, "value1"); + var db = muxer.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + await db.StringIncrementAsync(key, flags: CommandFlags.FireAndForget); + } + var result = await server.HotKeysGetAsync(); Assert.NotNull(result); Assert.True(result.TrackingActive); + CheckSimpleWithKey(key, result); Assert.True(await server.HotKeysStopAsync()); result = await server.HotKeysGetAsync(); Assert.NotNull(result); Assert.False(result.TrackingActive); - Assert.NotNull(result); + CheckSimpleWithKey(key, result); await server.HotKeysResetAsync(); result = await server.HotKeysGetAsync(); From 7a94e333ec264e622a5c0250898bf6a30fca30e9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 21:05:35 +0000 Subject: [PATCH 07/27] release notes and [Experimental] --- Directory.Build.props | 2 +- docs/ReleaseNotes.md | 4 + docs/exp/SER003.md | 25 ++++++ src/StackExchange.Redis/Experiments.cs | 2 + src/StackExchange.Redis/HotKeys.cs | 11 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 82 +++++++++---------- 6 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 docs/exp/SER003.md diff --git a/Directory.Build.props b/Directory.Build.props index 06542aa32..e36f0f7d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001;SER002 + $(NoWarn);NU5105;NU1507;SER001;SER002;SER003 https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes https://stackexchange.github.io/StackExchange.Redis/ MIT diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index f77a1d10a..c790a7259 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -6,6 +6,10 @@ Current package versions: | ------------ | ----------------- | ----- | | [![StackExchange.Redis](https://img.shields.io/nuget/v/StackExchange.Redis.svg)](https://www.nuget.org/packages/StackExchange.Redis/) | [![StackExchange.Redis](https://img.shields.io/nuget/vpre/StackExchange.Redis.svg)](https://www.nuget.org/packages/StackExchange.Redis/) | [![StackExchange.Redis MyGet](https://img.shields.io/myget/stackoverflow/vpre/StackExchange.Redis.svg)](https://www.myget.org/feed/stackoverflow/package/nuget/StackExchange.Redis) | +## unreleased + +- `HOTKEYS` support + ## 2.10.14 - Fix bug with connection startup failing in low-memory scenarios ([#3002 by nathan-miller23](https://github.com/StackExchange/StackExchange.Redis/pull/3002)) diff --git a/docs/exp/SER003.md b/docs/exp/SER003.md new file mode 100644 index 000000000..651434063 --- /dev/null +++ b/docs/exp/SER003.md @@ -0,0 +1,25 @@ +Redis 8.6 is currently in preview and may be subject to change. + +New features in Redis 8.6 include: + +- `HOTKEYS` for profiling CPU and network hot-spots by key +- `XADD IDMP[AUTP]` for idempotent (write-at-most-once) stream addition + +The corresponding library feature must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);SER003 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER003 +``` diff --git a/src/StackExchange.Redis/Experiments.cs b/src/StackExchange.Redis/Experiments.cs index 441b0ec54..547838873 100644 --- a/src/StackExchange.Redis/Experiments.cs +++ b/src/StackExchange.Redis/Experiments.cs @@ -12,6 +12,8 @@ internal static class Experiments public const string VectorSets = "SER001"; // ReSharper disable once InconsistentNaming public const string Server_8_4 = "SER002"; + // ReSharper disable once InconsistentNaming + public const string Server_8_6 = "SER003"; } } diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 66ce2ba54..891f80030 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; namespace StackExchange.Redis; @@ -14,6 +15,7 @@ public partial interface IServer /// Profiling frequency; effectively: measure every Nth command. /// The key-slots to record during this capture (defaults to "all"). /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] void HotKeysStart( HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default long count = 0, @@ -31,6 +33,7 @@ void HotKeysStart( /// Profiling frequency; effectively: measure every Nth command. /// The key-slots to record during this capture (defaults to "all"). /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] Task HotKeysStartAsync( HotKeysMetrics metrics = (HotKeysMetrics)~0, // everything by default long count = 0, @@ -43,24 +46,28 @@ Task HotKeysStartAsync( /// Stop the current HOTKEYS capture, if any. /// /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] bool HotKeysStop(CommandFlags flags = CommandFlags.None); /// /// Stop the current HOTKEYS capture, if any. /// /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] Task HotKeysStopAsync(CommandFlags flags = CommandFlags.None); /// /// Discard the last HOTKEYS capture data, if any. /// /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] void HotKeysReset(CommandFlags flags = CommandFlags.None); /// /// Discard the last HOTKEYS capture data, if any. /// /// The command flags to use. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] Task HotKeysResetAsync(CommandFlags flags = CommandFlags.None); /// @@ -68,6 +75,7 @@ Task HotKeysStartAsync( /// /// The command flags to use. /// The data captured during HOTKEYS profiling. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] HotKeysResult? HotKeysGet(CommandFlags flags = CommandFlags.None); /// @@ -75,6 +83,7 @@ Task HotKeysStartAsync( /// /// The command flags to use. /// The data captured during HOTKEYS profiling. + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] Task HotKeysGetAsync(CommandFlags flags = CommandFlags.None); } @@ -82,6 +91,7 @@ Task HotKeysStartAsync( /// Metrics to record during HOTKEYS profiling. /// [Flags] +[Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] public enum HotKeysMetrics { /// @@ -98,6 +108,7 @@ public enum HotKeysMetrics /// /// Captured data from HOTKEYS profiling. /// +[Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] public sealed partial class HotKeysResult { /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 8b02f2f9e..de69d4065 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,42 +1,42 @@ #nullable enable -override StackExchange.Redis.HotKeysResult.MetricKeyBytes.Equals(object? obj) -> bool -override StackExchange.Redis.HotKeysResult.MetricKeyBytes.GetHashCode() -> int -override StackExchange.Redis.HotKeysResult.MetricKeyBytes.ToString() -> string! -override StackExchange.Redis.HotKeysResult.MetricKeyCpu.Equals(object? obj) -> bool -override StackExchange.Redis.HotKeysResult.MetricKeyCpu.GetHashCode() -> int -override StackExchange.Redis.HotKeysResult.MetricKeyCpu.ToString() -> string! -StackExchange.Redis.HotKeysMetrics -StackExchange.Redis.HotKeysMetrics.Cpu = 1 -> StackExchange.Redis.HotKeysMetrics -StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics -StackExchange.Redis.HotKeysResult -StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan -StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime -StackExchange.Redis.HotKeysResult.CpuByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyCpu[]! -StackExchange.Redis.HotKeysResult.MetricKeyBytes -StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long -StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey -StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes() -> void -StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void -StackExchange.Redis.HotKeysResult.MetricKeyCpu -StackExchange.Redis.HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan -StackExchange.Redis.HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey -StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void -StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void -StackExchange.Redis.HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long -StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyBytes[]! -StackExchange.Redis.HotKeysResult.SampleRatio.get -> long -StackExchange.Redis.HotKeysResult.SelectedSlots.get -> StackExchange.Redis.SlotRange[]! -StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan -StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan -StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan -StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long -StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long -StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool -StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? -StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]StackExchange.Redis.HotKeysMetrics +[SER003]StackExchange.Redis.HotKeysMetrics.Cpu = 1 -> StackExchange.Redis.HotKeysMetrics +[SER003]StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics +[SER003]StackExchange.Redis.HotKeysResult +[SER003]StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime +[SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyCpu[]! +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes() -> void +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long +[SER003]StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyBytes[]! +[SER003]StackExchange.Redis.HotKeysResult.SampleRatio.get -> long +[SER003]StackExchange.Redis.HotKeysResult.SelectedSlots.get -> StackExchange.Redis.SlotRange[]! +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool +[SER003]StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? +[SER003]StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +[SER003]StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +[SER003]StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +[SER003]StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyBytes.Equals(object? obj) -> bool +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyBytes.GetHashCode() -> int +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyBytes.ToString() -> string! +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyCpu.Equals(object? obj) -> bool +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyCpu.GetHashCode() -> int +[SER003]override StackExchange.Redis.HotKeysResult.MetricKeyCpu.ToString() -> string! From 3c3726d7ead75b302ed39adf3bebb18c9f7cb2b8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 21:10:08 +0000 Subject: [PATCH 08/27] github link --- docs/ReleaseNotes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index c790a7259..c61197e0f 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,7 +8,7 @@ Current package versions: ## unreleased -- `HOTKEYS` support +- Add support for `HOTKEYS` ([#3008 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3008)) ## 2.10.14 From f41f9ff069d0e258b9ace438c84c693520562323 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 8 Feb 2026 21:16:02 +0000 Subject: [PATCH 09/27] sample ration default is 1, not zero --- src/StackExchange.Redis/HotKeys.StartMessage.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs index ec26745b0..529680206 100644 --- a/src/StackExchange.Redis/HotKeys.StartMessage.cs +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -45,7 +45,7 @@ [SLOTS count slot…] physical.WriteBulkString(Math.Ceiling(duration.TotalSeconds)); } - if (sampleRatio != 0) + if (sampleRatio != 1) { physical.WriteBulkString("SAMPLE"u8); physical.WriteBulkString(sampleRatio); @@ -71,7 +71,7 @@ public override int ArgCount if ((metrics & HotKeysMetrics.Network) != 0) argCount++; if (count != 0) argCount += 2; if (duration != TimeSpan.Zero) argCount += 2; - if (sampleRatio != 0) argCount += 2; + if (sampleRatio != 1) argCount += 2; if (slots is { Length: > 0 }) argCount += 2 + slots.Length; return argCount; } From 410ad1a8b22b22d458798204b1d3a0b48290b83b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 05:37:38 +0000 Subject: [PATCH 10/27] - RESP3 - don't expose raw arrays - expose API-shaped ms/us accessors - reuse shared all-slots array --- .../ClusterConfiguration.cs | 3 + .../HotKeys.ResultProcessor.cs | 20 ++++-- src/StackExchange.Redis/HotKeys.cs | 68 +++++++++++++------ .../PublicAPI/PublicAPI.Unshipped.txt | 15 ++-- .../StackExchange.Redis.Tests/HotKeysTests.cs | 37 ++++++---- 5 files changed, 97 insertions(+), 46 deletions(-) diff --git a/src/StackExchange.Redis/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index 99488ddff..63e74a228 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -45,6 +45,9 @@ private SlotRange(short from, short to) /// public int To => to; + internal const int MinSlot = 0, MaxSlot = 16383; + internal static readonly SlotRange[] SharedAllSlots = [new(MinSlot, MaxSlot)]; + /// /// Indicates whether two ranges are not equal. /// diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index e588fae8f..d1cbd5720 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -53,7 +53,7 @@ private HotKeysResult(in RawResult result) if (len == 0) continue; var items = value.GetItems().GetEnumerator(); - var slots = new SlotRange[len]; + var slots = len == 1 ? null : new SlotRange[len]; for (int i = 0; i < len && items.MoveNext(); i++) { ref readonly RawResult pair = ref items.Current; @@ -62,13 +62,21 @@ private HotKeysResult(in RawResult result) && pair[0].TryGetInt64(out var from) && pair[1].TryGetInt64(out var to)) { - slots[i] = new((int)from, (int)to); + if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) + { + slots = SlotRange.SharedAllSlots; // avoid the alloc + } + else + { + slots ??= new SlotRange[len]; + slots[i] = new((int)from, (int)to); + } } } - SelectedSlots = slots; + _selectedSlots = slots; break; case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): - TotalCpuTimeMilliseconds = i64; + TotalCpuTimeMicroseconds = i64; break; case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): TotalNetworkBytes = i64; @@ -103,7 +111,7 @@ private HotKeysResult(in RawResult result) } } - CpuByKey = cpuTime; + _cpuByKey = cpuTime; break; case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: len = value.ItemsCount / 2; @@ -120,7 +128,7 @@ private HotKeysResult(in RawResult result) } } - NetworkBytesByKey = netBytes; + _networkBytesByKey = netBytes; break; } } diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 891f80030..43b4ed9be 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -124,47 +124,73 @@ public sealed partial class HotKeysResult /// /// The key slots active for this profiling session. /// - public SlotRange[] SelectedSlots { get; } = []; + public ReadOnlySpan SelectedSlots => _selectedSlots; + + private readonly SlotRange[]? _selectedSlots; /// /// The total CPU measured for all commands in all slots. /// - public TimeSpan TotalCpuTime => TimeSpan.FromMilliseconds(TotalCpuTimeMilliseconds); + public TimeSpan TotalCpuTime => NonNegativeMicroseconds(TotalCpuTimeMicroseconds); + + private static TimeSpan NonNegativeMilliseconds(long ms) + => TimeSpan.FromMilliseconds(Math.Max(ms, 0)); - private long TotalCpuTimeMilliseconds { get; } + private static TimeSpan NonNegativeMicroseconds(long us) + { + const long TICKS_PER_MICROSECOND = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer + return TimeSpan.FromTicks(Math.Max(us, 0) / TICKS_PER_MICROSECOND); + } + + /// + /// The total CPU measured for all commands in all slots. + /// + public long TotalCpuTimeMicroseconds { get; } = -1; /// /// The total network usage measured for all commands in all slots. /// public long TotalNetworkBytes { get; } - private long CollectionStartTimeUnixMilliseconds { get; } + /// + /// The start time of the capture. + /// + public long CollectionStartTimeUnixMilliseconds { get; } = -1; /// /// The start time of the capture. /// - public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(CollectionStartTimeUnixMilliseconds); + public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(Math.Max(CollectionStartTimeUnixMilliseconds, 0)); - private long CollectionDurationMilliseconds { get; } + /// + /// The duration of the capture. + /// + public long CollectionDurationMilliseconds { get; } /// /// The duration of the capture. /// - public TimeSpan CollectionDuration => TimeSpan.FromMilliseconds(CollectionDurationMilliseconds); + public TimeSpan CollectionDuration => NonNegativeMilliseconds(CollectionDurationMilliseconds); - private long TotalCpuTimeUserMilliseconds { get; } + /// + /// The total user CPU time measured. + /// + public long TotalCpuTimeUserMilliseconds { get; } = -1; /// /// The total user CPU time measured. /// - public TimeSpan TotalCpuTimeUser => TimeSpan.FromMilliseconds(TotalCpuTimeUserMilliseconds); + public TimeSpan TotalCpuTimeUser => NonNegativeMilliseconds(TotalCpuTimeUserMilliseconds); - private long TotalCpuTimeSystemMilliseconds { get; } + /// + /// The total system CPU measured. + /// + public long TotalCpuTimeSystemMilliseconds { get; } = -1; /// /// The total system CPU measured. /// - public TimeSpan TotalCpuTimeSystem => TimeSpan.FromMilliseconds(TotalCpuTimeSystemMilliseconds); + public TimeSpan TotalCpuTimeSystem => NonNegativeMilliseconds(TotalCpuTimeSystemMilliseconds); /// /// The total network data measured. @@ -178,21 +204,23 @@ public sealed partial class HotKeysResult /// /// Hot keys, as measured by CPU activity. /// - public MetricKeyCpu[] CpuByKey { get; } = []; + public ReadOnlySpan CpuByKey => _cpuByKey; + + private readonly MetricKeyCpu[]? _cpuByKey; /// /// Hot keys, as measured by network activity. /// - public MetricKeyBytes[] NetworkBytesByKey { get; } = []; + public ReadOnlySpan NetworkBytesByKey => _networkBytesByKey; - private const long TicksPerMicroSeconds = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer + private readonly MetricKeyBytes[]? _networkBytesByKey; /// /// A hot key, as measured by CPU activity. /// /// The key observed. - /// The time taken, in microseconds. - public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) + /// The time taken, in microseconds. + public readonly struct MetricKeyCpu(in RedisKey key, long durationMicroseconds) { private readonly RedisKey _key = key; @@ -204,22 +232,22 @@ public readonly struct MetricKeyCpu(in RedisKey key, long microSeconds) /// /// The time taken, in microseconds. /// - public long MicroSeconds => microSeconds; + public long DurationMicroseconds => durationMicroseconds; /// /// The time taken. /// - public TimeSpan Duration => TimeSpan.FromTicks(microSeconds / TicksPerMicroSeconds); + public TimeSpan Duration => NonNegativeMicroseconds(durationMicroseconds); /// public override string ToString() => $"{_key}: {Duration}"; /// - public override int GetHashCode() => _key.GetHashCode() ^ microSeconds.GetHashCode(); + public override int GetHashCode() => _key.GetHashCode() ^ durationMicroseconds.GetHashCode(); /// public override bool Equals(object? obj) - => obj is MetricKeyCpu other && _key.Equals(other.Key) && MicroSeconds == other.MicroSeconds; + => obj is MetricKeyCpu other && _key.Equals(other.Key) && durationMicroseconds == DurationMicroseconds; } /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index de69d4065..3d0211238 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -4,8 +4,10 @@ [SER003]StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysResult [SER003]StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.CollectionDurationMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime -[SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyCpu[]! +[SER003]StackExchange.Redis.HotKeysResult.CollectionStartTimeUnixMilliseconds.get -> long +[SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey @@ -13,16 +15,19 @@ [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.DurationMicroseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void -[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long microSeconds) -> void -[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MicroSeconds.get -> long -[SER003]StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> StackExchange.Redis.HotKeysResult.MetricKeyBytes[]! +[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long durationMicroseconds) -> void +[SER003]StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.SampleRatio.get -> long -[SER003]StackExchange.Redis.HotKeysResult.SelectedSlots.get -> StackExchange.Redis.SlotRange[]! +[SER003]StackExchange.Redis.HotKeysResult.SelectedSlots.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeMicroseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystemMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUserMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long [SER003]StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index 9d298cd39..ab5c58e82 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -4,6 +4,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(NonParallelCollection.Name)] public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { @@ -78,25 +79,31 @@ public void CanStartStopReset() private static void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys) { - Assert.True(hotKeys.CollectionDuration > TimeSpan.Zero); - Assert.True(hotKeys.CollectionStartTime > new DateTime(2026, 2, 1)); - var cpu = Assert.Single(hotKeys.CpuByKey); + Assert.True(hotKeys.CollectionDurationMilliseconds >= 0, nameof(hotKeys.CollectionDurationMilliseconds)); + Assert.True(hotKeys.CollectionStartTimeUnixMilliseconds >= 0, nameof(hotKeys.CollectionStartTimeUnixMilliseconds)); + + Assert.Equal(1, hotKeys.CpuByKey.Length); + var cpu = hotKeys.CpuByKey[0]; Assert.Equal(key, cpu.Key); - Assert.True(cpu.Duration > TimeSpan.Zero); - var net = Assert.Single(hotKeys.NetworkBytesByKey); + Assert.True(cpu.DurationMicroseconds >= 0, nameof(cpu.DurationMicroseconds)); + + Assert.Equal(1, hotKeys.NetworkBytesByKey.Length); + var net = hotKeys.NetworkBytesByKey[0]; Assert.Equal(key, net.Key); - Assert.True(net.Bytes > 0); + Assert.True(net.Bytes > 0, nameof(net.Bytes)); Assert.Equal(1, hotKeys.SampleRatio); - var slots = Assert.Single(hotKeys.SelectedSlots); - Assert.Equal(0, slots.From); - Assert.Equal(16383, slots.To); - - Assert.True(hotKeys.TotalCpuTime > TimeSpan.Zero); - Assert.True(hotKeys.TotalCpuTimeSystem >= TimeSpan.Zero); - Assert.True(hotKeys.TotalCpuTimeUser >= TimeSpan.Zero); - Assert.True(hotKeys.TotalNetworkBytes > 0); - Assert.True(hotKeys.TotalNetworkBytes2 > 0); + + Assert.Equal(1, hotKeys.SelectedSlots.Length); + var slots = hotKeys.SelectedSlots[0]; + Assert.Equal(SlotRange.MinSlot, slots.From); + Assert.Equal(SlotRange.MaxSlot, slots.To); + + Assert.True(hotKeys.TotalCpuTimeMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeMicroseconds)); + Assert.True(hotKeys.TotalCpuTimeSystemMilliseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMilliseconds)); + Assert.True(hotKeys.TotalCpuTimeUserMilliseconds >= 0, nameof(hotKeys.TotalCpuTimeUserMilliseconds)); + Assert.True(hotKeys.TotalNetworkBytes > 0, nameof(hotKeys.TotalNetworkBytes)); + Assert.True(hotKeys.TotalNetworkBytes2 > 0, nameof(hotKeys.TotalNetworkBytes2)); } [Fact] From 4fe582c73e9c1621c5a1f64dfae7eb7478edffd4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 09:15:52 +0000 Subject: [PATCH 11/27] validate/fix cluster slot filter --- .../HotKeys.ResultProcessor.cs | 22 ++++-- .../StackExchange.Redis.Tests/HotKeysTests.cs | 73 ++++++++++++++++--- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index d1cbd5720..0ea8c3c77 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -57,13 +57,25 @@ private HotKeysResult(in RawResult result) for (int i = 0; i < len && items.MoveNext(); i++) { ref readonly RawResult pair = ref items.Current; - if (pair.Resp2TypeArray is ResultType.Array - && pair.ItemsCount == 2 - && pair[0].TryGetInt64(out var from) - && pair[1].TryGetInt64(out var to)) + if (pair.Resp2TypeArray is ResultType.Array) { - if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) + long from = -1, to = -1; + switch (pair.ItemsCount) { + case 1 when pair[0].TryGetInt64(out from): + to = from; // single slot + break; + case 2 when pair[0].TryGetInt64(out from) && pair[1].TryGetInt64(out to): + break; + } + + if (from < SlotRange.MinSlot) + { + // skip invalid ranges + } + else if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) + { + // this is the "normal" case when no slot filter was applied slots = SlotRange.SharedAllSlots; // avoid the alloc } else diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index ab5c58e82..c9d93fa40 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -4,16 +4,53 @@ namespace StackExchange.Redis.Tests; +public class HotKeysClusterTests(ITestOutputHelper output, SharedConnectionFixture fixture) : HotKeysTests(output, fixture) +{ + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; + + [Fact] + public void CanUseClusterFilter() + { + var key = Me(); + using var muxer = GetServer(key, out var server); + Log($"server: {Format.ToString(server.EndPoint)}, key: '{key}'"); + + var slot = muxer.HashSlot(key); + server.HotKeysStart(slots: [(short)slot]); + + var db = muxer.GetDatabase(); + db.KeyDelete(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + db.StringIncrement(key, flags: CommandFlags.FireAndForget); + } + + server.HotKeysStop(); + var result = server.HotKeysGet(); + Assert.NotNull(result); + var slots = result.SelectedSlots; + Assert.Equal(1, slots.Length); + Assert.Equal(slot, slots[0].From); + Assert.Equal(slot, slots[0].To); + + Assert.Equal(1, result.CpuByKey.Length); + Assert.Equal(key, result.CpuByKey[0].Key); + + Assert.Equal(1, result.NetworkBytesByKey.Length); + Assert.Equal(key, result.NetworkBytesByKey[0].Key); + } +} + [RunPerProtocol] [Collection(NonParallelCollection.Name)] public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { - private IInternalConnectionMultiplexer GetServer(out IServer server) + private protected IInternalConnectionMultiplexer GetServer(out IServer server) => GetServer(RedisKey.Null, out server); - private IInternalConnectionMultiplexer GetServer(in RedisKey key, out IServer server) + private protected IInternalConnectionMultiplexer GetServer(in RedisKey key, out IServer server) { - var muxer = Create(require: RedisFeatures.v8_4_0_rc1); // TODO: 8.6 + var muxer = Create(require: RedisFeatures.v8_4_0_rc1, allowAdmin: true); // TODO: 8.6 server = key.IsNull ? muxer.GetServer(muxer.GetEndPoints()[0]) : muxer.GetServer(key); server.HotKeysStop(CommandFlags.FireAndForget); server.HotKeysReset(CommandFlags.FireAndForget); @@ -64,20 +101,20 @@ public void CanStartStopReset() var result = server.HotKeysGet(); Assert.NotNull(result); Assert.True(result.TrackingActive); - CheckSimpleWithKey(key, result); + CheckSimpleWithKey(key, result, server); Assert.True(server.HotKeysStop()); result = server.HotKeysGet(); Assert.NotNull(result); Assert.False(result.TrackingActive); - CheckSimpleWithKey(key, result); + CheckSimpleWithKey(key, result, server); server.HotKeysReset(); result = server.HotKeysGet(); Assert.Null(result); } - private static void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys) + private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer server) { Assert.True(hotKeys.CollectionDurationMilliseconds >= 0, nameof(hotKeys.CollectionDurationMilliseconds)); Assert.True(hotKeys.CollectionStartTimeUnixMilliseconds >= 0, nameof(hotKeys.CollectionStartTimeUnixMilliseconds)); @@ -94,10 +131,22 @@ private static void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys) Assert.Equal(1, hotKeys.SampleRatio); - Assert.Equal(1, hotKeys.SelectedSlots.Length); - var slots = hotKeys.SelectedSlots[0]; - Assert.Equal(SlotRange.MinSlot, slots.From); - Assert.Equal(SlotRange.MaxSlot, slots.To); + if (server.ServerType is ServerType.Cluster) + { + Assert.NotEqual(0, hotKeys.SelectedSlots.Length); + Log("Cluster mode detected; not enforcing slots, but:"); + foreach (var slot in hotKeys.SelectedSlots) + { + Log($" {slot}"); + } + } + else + { + Assert.Equal(1, hotKeys.SelectedSlots.Length); + var slots = hotKeys.SelectedSlots[0]; + Assert.Equal(SlotRange.MinSlot, slots.From); + Assert.Equal(SlotRange.MaxSlot, slots.To); + } Assert.True(hotKeys.TotalCpuTimeMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeMicroseconds)); Assert.True(hotKeys.TotalCpuTimeSystemMilliseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMilliseconds)); @@ -122,13 +171,13 @@ public async Task CanStartStopResetAsync() var result = await server.HotKeysGetAsync(); Assert.NotNull(result); Assert.True(result.TrackingActive); - CheckSimpleWithKey(key, result); + CheckSimpleWithKey(key, result, server); Assert.True(await server.HotKeysStopAsync()); result = await server.HotKeysGetAsync(); Assert.NotNull(result); Assert.False(result.TrackingActive); - CheckSimpleWithKey(key, result); + CheckSimpleWithKey(key, result, server); await server.HotKeysResetAsync(); result = await server.HotKeysGetAsync(); From 217ea429cc1251ea76be4dce470f5cd200fe2364 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 09:22:24 +0000 Subject: [PATCH 12/27] validate duration --- .../StackExchange.Redis.Tests/HotKeysTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index c9d93fa40..772f8caf4 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -183,4 +183,32 @@ public async Task CanStartStopResetAsync() result = await server.HotKeysGetAsync(); Assert.Null(result); } + + [Fact] + public async Task DurationFilterAsync() + { + Skip.UnlessLongRunning(); // time-based tests are horrible + + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + await server.HotKeysStartAsync(duration: TimeSpan.FromSeconds(1)); + var db = muxer.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + await db.StringIncrementAsync(key, flags: CommandFlags.FireAndForget); + } + var before = await server.HotKeysGetAsync(); + await Task.Delay(TimeSpan.FromSeconds(2)); + var after = await server.HotKeysGetAsync(); + + Assert.NotNull(before); + Assert.True(before.TrackingActive); + + Assert.NotNull(after); + Assert.False(after.TrackingActive); + + Log($"Duration: {after.CollectionDurationMilliseconds}ms"); + Assert.True(after.CollectionDurationMilliseconds > 900 && after.CollectionDurationMilliseconds < 1100); + } } From 62899aef2ae675f9d54557c30fbe4a6c6cd480bb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:29:59 +0000 Subject: [PATCH 13/27] docs; more tests and compensation --- docs/HotKeys.md | 52 +++++ docs/index.md | 1 + .../HotKeys.ResultProcessor.cs | 207 ++++++++++-------- src/StackExchange.Redis/HotKeys.Server.cs | 4 +- .../HotKeys.StartMessage.cs | 2 +- src/StackExchange.Redis/HotKeys.cs | 72 +++--- .../PublicAPI/PublicAPI.Unshipped.txt | 19 +- .../StackExchange.Redis.Tests/HotKeysTests.cs | 39 +++- 8 files changed, 241 insertions(+), 155 deletions(-) create mode 100644 docs/HotKeys.md diff --git a/docs/HotKeys.md b/docs/HotKeys.md new file mode 100644 index 000000000..30f879c3c --- /dev/null +++ b/docs/HotKeys.md @@ -0,0 +1,52 @@ +Hot Keys +=== + +The `HOTKEYS` command allows for server-side profiling of CPU and network usage by key. It is available in Redis 8.6 and later. + +This command is available via the `IServer.HotKeys*` methods: + +``` c# +// Get the server instance. +var server = muxer.GetServer(endpoint); + +// Start the capture; you can specify a duration, or manually use the HotKeysStop[Async] method; specifying +// a duration is recommended, so that the profiler will not be left running in the case of failure. +// Optional parameters allow you to specify the metrics to capture, the sample ratio, and the key slots to include; +// by default, all metrics are captured, every command is sampled, and all key slots are included. +await server.HotKeysStartAsync(duration: TimeSpan.FromSeconds(30)); + +// Wow either do some work ourselves, or await for some other activity to happen: +await Task.Delay(TimeSpan.FromSeconds(35)); // whatever happens: happens + +// Fetch the results; note that this does not stop the capture, and you can fetch the results multiple times +// either while it is running, or after it has completed - but only a single capture can be active at a time. +var result = await server.HotKeysGetAsync(); + +// ...investigate the results... + +// Optional: discard the active capture data at the server, if any. +await server.HotKeysResetAsync(); +``` + +The `HotKeysResult` class (our `result` value above) contains the following properties: + +- `Metrics`: The metrics captured during this profiling session. +- `TrackingActive`: Indicates whether the capture currently active. +- `SampleRatio`: Profiling frequency; effectively: measure every Nth command. +- `SelectedSlots`: The key slots active for this profiling session. +- `CollectionStartTime`: The start time of the capture. +- `CollectionDuration`: The duration of the capture. +- `TotalNetworkBytes`: The total network usage measured for all commands in all slots, without any sampling or filtering applied. +- `TotalCpuTime`: The total CPU time measured for all commands in all slots, without any sampling or filtering applied. + +If CPU metrics were captured, the following properties are also available: + +- `TotalProfiledCpuTimeUser`: The total user CPU time measured in the profiling session. +- `TotalProfiledCpuTimeSystem`: The total system CPU time measured in the profiling session. +- `TotalProfiledCpuTime`: The total CPU time measured in the profiling session. +- `CpuByKey`: Hot keys, as measured by CPU activity. + +If network metrics were captured, the following properties are also available: + +- `TotalProfiledNetworkBytes`: The total network data measured in the profiling session. +- `NetworkBytesByKey`: Hot keys, as measured by network activity. diff --git a/docs/index.md b/docs/index.md index b1498d878..9180d3423 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,7 @@ Documentation - [Events](Events) - the events available for logging / information purposes - [Pub/Sub Message Order](PubSubOrder) - advice on sequential and concurrent processing - [Pub/Sub Key Notifications](KeyspaceNotifications) - how to use keyspace and keyevent notifications +- [Hot Keys](HotKeys) - how to use `HOTKEYS` profiling - [Using RESP3](Resp3) - information on using RESP3 - [ServerMaintenanceEvent](ServerMaintenanceEvent) - how to listen and prepare for hosted server maintenance (e.g. Azure Cache for Redis) - [Streams](Streams) - how to use the Stream data type diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 0ea8c3c77..91d276676 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -32,119 +32,135 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private HotKeysResult(in RawResult result) { + var metrics = HotKeysMetrics.None; // we infer this from the keys present var iter = result.GetItems().GetEnumerator(); while (iter.MoveNext()) { ref readonly RawResult key = ref iter.Current; - if (iter.MoveNext()) + if (!iter.MoveNext()) break; // lies about the length! + ref readonly RawResult value = ref iter.Current; + var hash = key.Payload.Hash64(); + switch (hash) { - ref readonly RawResult value = ref iter.Current; - var hash = key.Payload.Hash64(); - switch (hash) - { - case tracking_active.Hash when tracking_active.Is(hash, key): - TrackingActive = value.GetBoolean(); - break; - case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out var i64): - SampleRatio = i64; - break; - case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: - var len = value.ItemsCount; - if (len == 0) continue; + case tracking_active.Hash when tracking_active.Is(hash, key): + TrackingActive = value.GetBoolean(); + break; + case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out var i64): + SampleRatio = i64; + break; + case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + var len = value.ItemsCount; + if (len == 0) continue; - var items = value.GetItems().GetEnumerator(); - var slots = len == 1 ? null : new SlotRange[len]; - for (int i = 0; i < len && items.MoveNext(); i++) + var items = value.GetItems().GetEnumerator(); + var slots = len == 1 ? null : new SlotRange[len]; + for (int i = 0; i < len && items.MoveNext(); i++) + { + ref readonly RawResult pair = ref items.Current; + if (pair.Resp2TypeArray is ResultType.Array) { - ref readonly RawResult pair = ref items.Current; - if (pair.Resp2TypeArray is ResultType.Array) + long from = -1, to = -1; + switch (pair.ItemsCount) { - long from = -1, to = -1; - switch (pair.ItemsCount) - { - case 1 when pair[0].TryGetInt64(out from): - to = from; // single slot - break; - case 2 when pair[0].TryGetInt64(out from) && pair[1].TryGetInt64(out to): - break; - } + case 1 when pair[0].TryGetInt64(out from): + to = from; // single slot + break; + case 2 when pair[0].TryGetInt64(out from) && pair[1].TryGetInt64(out to): + break; + } - if (from < SlotRange.MinSlot) - { - // skip invalid ranges - } - else if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) - { - // this is the "normal" case when no slot filter was applied - slots = SlotRange.SharedAllSlots; // avoid the alloc - } - else - { - slots ??= new SlotRange[len]; - slots[i] = new((int)from, (int)to); - } + if (from < SlotRange.MinSlot) + { + // skip invalid ranges + } + else if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) + { + // this is the "normal" case when no slot filter was applied + slots = SlotRange.SharedAllSlots; // avoid the alloc + } + else + { + slots ??= new SlotRange[len]; + slots[i] = new((int)from, (int)to); } } - _selectedSlots = slots; - break; - case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): - TotalCpuTimeMicroseconds = i64; - break; - case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): - TotalNetworkBytes = i64; - break; - case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): - CollectionStartTimeUnixMilliseconds = i64; - break; - case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out var i64): - CollectionDurationMilliseconds = i64; - break; - case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out var i64): - TotalCpuTimeSystemMilliseconds = i64; - break; - case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out var i64): - TotalCpuTimeUserMilliseconds = i64; - break; - case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out var i64): - TotalNetworkBytes2 = i64; - break; - case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: - len = value.ItemsCount / 2; - if (len == 0) continue; + } + _selectedSlots = slots; + break; + case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + TotalCpuTimeMicroseconds = i64; + break; + case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): + TotalNetworkBytes = i64; + break; + case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): + CollectionStartTimeUnixMilliseconds = i64; + break; + case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out var i64): + CollectionDurationMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller + break; + case collection_duration_us.Hash when collection_duration_us.Is(hash, key) && value.TryGetInt64(out var i64): + CollectionDurationMicroseconds = i64; + break; + case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out var i64): + metrics |= HotKeysMetrics.Cpu; + TotalCpuTimeSystemMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller + break; + case total_cpu_time_sys_us.Hash when total_cpu_time_sys_us.Is(hash, key) && value.TryGetInt64(out var i64): + metrics |= HotKeysMetrics.Cpu; + TotalCpuTimeSystemMicroseconds = i64; + break; + case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out var i64): + metrics |= HotKeysMetrics.Cpu; + TotalCpuTimeUserMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller + break; + case total_cpu_time_user_us.Hash when total_cpu_time_user_us.Is(hash, key) && value.TryGetInt64(out var i64): + metrics |= HotKeysMetrics.Cpu; + TotalCpuTimeUserMicroseconds = i64; + break; + case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out var i64): + metrics |= HotKeysMetrics.Network; + TotalProfiledNetworkBytes = i64; + break; + case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + metrics |= HotKeysMetrics.Cpu; + len = value.ItemsCount / 2; + if (len == 0) continue; - var cpuTime = new MetricKeyCpu[len]; - items = value.GetItems().GetEnumerator(); - for (int i = 0; i < len && items.MoveNext(); i++) + var cpuTime = new MetricKeyCpu[len]; + items = value.GetItems().GetEnumerator(); + for (int i = 0; i < len && items.MoveNext(); i++) + { + var metricKey = items.Current.AsRedisKey(); + if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) { - var metricKey = items.Current.AsRedisKey(); - if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) - { - cpuTime[i] = new(metricKey, metricValue); - } + cpuTime[i] = new(metricKey, metricValue); } + } - _cpuByKey = cpuTime; - break; - case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: - len = value.ItemsCount / 2; - if (len == 0) continue; + _cpuByKey = cpuTime; + break; + case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + metrics |= HotKeysMetrics.Network; + len = value.ItemsCount / 2; + if (len == 0) continue; - var netBytes = new MetricKeyBytes[len]; - items = value.GetItems().GetEnumerator(); - for (int i = 0; i < len && items.MoveNext(); i++) + var netBytes = new MetricKeyBytes[len]; + items = value.GetItems().GetEnumerator(); + for (int i = 0; i < len && items.MoveNext(); i++) + { + var metricKey = items.Current.AsRedisKey(); + if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) { - var metricKey = items.Current.AsRedisKey(); - if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) - { - netBytes[i] = new(metricKey, metricValue); - } + netBytes[i] = new(metricKey, metricValue); } + } - _networkBytesByKey = netBytes; - break; - } - } - } + _networkBytesByKey = netBytes; + break; + } // switch + } // while + Metrics = metrics; } #pragma warning disable SA1134, SA1300 @@ -156,8 +172,11 @@ [FastHash] internal static partial class all_commands_all_slots_us { } [FastHash] internal static partial class net_bytes_all_commands_all_slots { } [FastHash] internal static partial class collection_start_time_unix_ms { } [FastHash] internal static partial class collection_duration_ms { } + [FastHash] internal static partial class collection_duration_us { } [FastHash] internal static partial class total_cpu_time_user_ms { } + [FastHash] internal static partial class total_cpu_time_user_us { } [FastHash] internal static partial class total_cpu_time_sys_ms { } + [FastHash] internal static partial class total_cpu_time_sys_us { } [FastHash] internal static partial class total_net_bytes { } [FastHash] internal static partial class by_cpu_time_us { } [FastHash] internal static partial class by_net_bytes { } diff --git a/src/StackExchange.Redis/HotKeys.Server.cs b/src/StackExchange.Redis/HotKeys.Server.cs index 529de18c1..967a454e8 100644 --- a/src/StackExchange.Redis/HotKeys.Server.cs +++ b/src/StackExchange.Redis/HotKeys.Server.cs @@ -10,7 +10,7 @@ public void HotKeysStart( long count = 0, TimeSpan duration = default, long sampleRatio = 1, - short[]? slots = null, + int[]? slots = null, CommandFlags flags = CommandFlags.None) => ExecuteSync( new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), @@ -21,7 +21,7 @@ public Task HotKeysStartAsync( long count = 0, TimeSpan duration = default, long sampleRatio = 1, - short[]? slots = null, + int[]? slots = null, CommandFlags flags = CommandFlags.None) => ExecuteAsync( new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), diff --git a/src/StackExchange.Redis/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs index 529680206..c9f0bc371 100644 --- a/src/StackExchange.Redis/HotKeys.StartMessage.cs +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -11,7 +11,7 @@ internal sealed class HotKeysStartMessage( long count, TimeSpan duration, long sampleRatio, - short[]? slots) : Message(-1, flags, RedisCommand.HOTKEYS) + int[]? slots) : Message(-1, flags, RedisCommand.HOTKEYS) { protected override void WriteImpl(PhysicalConnection physical) { diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 43b4ed9be..84304fed2 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -21,17 +21,17 @@ void HotKeysStart( long count = 0, TimeSpan duration = default, long sampleRatio = 1, - short[]? slots = null, + int[]? slots = null, CommandFlags flags = CommandFlags.None); /// /// Start a new HOTKEYS profiling session. /// /// The metrics to record during this capture (defaults to "all"). - /// The total number of operations to profile. + /// The number of keys to retain and report when is invoked. /// The duration of this profiling session. /// Profiling frequency; effectively: measure every Nth command. - /// The key-slots to record during this capture (defaults to "all"). + /// The key-slots to record during this capture (defaults to "all" / "all on this node"). /// The command flags to use. [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] Task HotKeysStartAsync( @@ -39,7 +39,7 @@ Task HotKeysStartAsync( long count = 0, TimeSpan duration = default, long sampleRatio = 1, - short[]? slots = null, + int[]? slots = null, CommandFlags flags = CommandFlags.None); /// @@ -94,6 +94,11 @@ Task HotKeysStartAsync( [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] public enum HotKeysMetrics { + /// + /// No metrics. + /// + None = 0, + /// /// Capture CPU time. /// @@ -111,6 +116,11 @@ public enum HotKeysMetrics [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] public sealed partial class HotKeysResult { + /// + /// The metrics captured during this profiling session. + /// + public HotKeysMetrics Metrics { get; } + /// /// Indicates whether the capture currently active. /// @@ -129,73 +139,60 @@ public sealed partial class HotKeysResult private readonly SlotRange[]? _selectedSlots; /// - /// The total CPU measured for all commands in all slots. + /// The total CPU measured for all commands in all slots, without any sampling or filtering applied. /// public TimeSpan TotalCpuTime => NonNegativeMicroseconds(TotalCpuTimeMicroseconds); - private static TimeSpan NonNegativeMilliseconds(long ms) - => TimeSpan.FromMilliseconds(Math.Max(ms, 0)); - private static TimeSpan NonNegativeMicroseconds(long us) { const long TICKS_PER_MICROSECOND = TimeSpan.TicksPerMillisecond / 1000; // 10, but: clearer return TimeSpan.FromTicks(Math.Max(us, 0) / TICKS_PER_MICROSECOND); } - /// - /// The total CPU measured for all commands in all slots. - /// - public long TotalCpuTimeMicroseconds { get; } = -1; + internal long TotalCpuTimeMicroseconds { get; } = -1; /// - /// The total network usage measured for all commands in all slots. + /// The total network usage measured for all commands in all slots, without any sampling or filtering applied. /// public long TotalNetworkBytes { get; } - /// - /// The start time of the capture. - /// - public long CollectionStartTimeUnixMilliseconds { get; } = -1; + internal long CollectionStartTimeUnixMilliseconds { get; } = -1; /// /// The start time of the capture. /// public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(Math.Max(CollectionStartTimeUnixMilliseconds, 0)); - /// - /// The duration of the capture. - /// - public long CollectionDurationMilliseconds { get; } + internal long CollectionDurationMicroseconds { get; } /// /// The duration of the capture. /// - public TimeSpan CollectionDuration => NonNegativeMilliseconds(CollectionDurationMilliseconds); + public TimeSpan CollectionDuration => NonNegativeMicroseconds(CollectionDurationMicroseconds); - /// - /// The total user CPU time measured. - /// - public long TotalCpuTimeUserMilliseconds { get; } = -1; + internal long TotalCpuTimeUserMicroseconds { get; } = -1; /// - /// The total user CPU time measured. + /// The total user CPU time measured in the profiling session. /// - public TimeSpan TotalCpuTimeUser => NonNegativeMilliseconds(TotalCpuTimeUserMilliseconds); + public TimeSpan TotalProfiledCpuTimeUser => NonNegativeMicroseconds(TotalCpuTimeUserMicroseconds); + + internal long TotalCpuTimeSystemMicroseconds { get; } = -1; /// - /// The total system CPU measured. + /// The total system CPU measured in the profiling session. /// - public long TotalCpuTimeSystemMilliseconds { get; } = -1; + public TimeSpan TotalProfiledCpuTimeSystem => NonNegativeMicroseconds(TotalCpuTimeSystemMicroseconds); /// - /// The total system CPU measured. + /// The total CPU time measured in the profiling session (this is just + ). /// - public TimeSpan TotalCpuTimeSystem => NonNegativeMilliseconds(TotalCpuTimeSystemMilliseconds); + public TimeSpan TotalProfiledCpuTime => TotalProfiledCpuTimeUser + TotalProfiledCpuTimeSystem; /// - /// The total network data measured. + /// The total network data measured in the profiling session. /// - public long TotalNetworkBytes2 { get; } // total-net-bytes vs net-bytes-all-commands-all-slots + public long TotalProfiledNetworkBytes { get; } // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking // for a particular key (lookup), but rather: is likely to want to list them for display; this way, @@ -229,10 +226,7 @@ public readonly struct MetricKeyCpu(in RedisKey key, long durationMicroseconds) /// public RedisKey Key => _key; - /// - /// The time taken, in microseconds. - /// - public long DurationMicroseconds => durationMicroseconds; + internal long DurationMicroseconds => durationMicroseconds; /// /// The time taken. @@ -247,7 +241,7 @@ public readonly struct MetricKeyCpu(in RedisKey key, long durationMicroseconds) /// public override bool Equals(object? obj) - => obj is MetricKeyCpu other && _key.Equals(other.Key) && durationMicroseconds == DurationMicroseconds; + => obj is MetricKeyCpu other && _key.Equals(other.Key) && durationMicroseconds == other.DurationMicroseconds; } /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 3d0211238..075d1fbae 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -2,11 +2,10 @@ [SER003]StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysMetrics.Cpu = 1 -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics +[SER003]StackExchange.Redis.HotKeysMetrics.None = 0 -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysResult [SER003]StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.CollectionDurationMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime -[SER003]StackExchange.Redis.HotKeysResult.CollectionStartTimeUnixMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long @@ -15,28 +14,26 @@ [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.MetricKeyBytes(in StackExchange.Redis.RedisKey key, long bytes) -> void [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Duration.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.DurationMicroseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.Key.get -> StackExchange.Redis.RedisKey [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu() -> void [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long durationMicroseconds) -> void +[SER003]StackExchange.Redis.HotKeysResult.Metrics.get -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.SampleRatio.get -> long [SER003]StackExchange.Redis.HotKeysResult.SelectedSlots.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeMicroseconds.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystemMilliseconds.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUserMilliseconds.get -> long [SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes2.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeSystem.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeUser.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledNetworkBytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool [SER003]StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? [SER003]StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER003]StackExchange.Redis.IServer.HotKeysReset(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void [SER003]StackExchange.Redis.IServer.HotKeysResetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER003]StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void -[SER003]StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, short[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +[SER003]StackExchange.Redis.IServer.HotKeysStart(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, int[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +[SER003]StackExchange.Redis.IServer.HotKeysStartAsync(StackExchange.Redis.HotKeysMetrics metrics = (StackExchange.Redis.HotKeysMetrics)-1, long count = 0, System.TimeSpan duration = default(System.TimeSpan), long sampleRatio = 1, int[]? slots = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER003]StackExchange.Redis.IServer.HotKeysStop(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool [SER003]StackExchange.Redis.IServer.HotKeysStopAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER003]override StackExchange.Redis.HotKeysResult.MetricKeyBytes.Equals(object? obj) -> bool diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index 772f8caf4..07201b8e7 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -45,10 +45,10 @@ public void CanUseClusterFilter() [Collection(NonParallelCollection.Name)] public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { - private protected IInternalConnectionMultiplexer GetServer(out IServer server) + private protected IConnectionMultiplexer GetServer(out IServer server) => GetServer(RedisKey.Null, out server); - private protected IInternalConnectionMultiplexer GetServer(in RedisKey key, out IServer server) + private protected IConnectionMultiplexer GetServer(in RedisKey key, out IServer server) { var muxer = Create(require: RedisFeatures.v8_4_0_rc1, allowAdmin: true); // TODO: 8.6 server = key.IsNull ? muxer.GetServer(muxer.GetEndPoints()[0]) : muxer.GetServer(key); @@ -116,7 +116,8 @@ public void CanStartStopReset() private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer server) { - Assert.True(hotKeys.CollectionDurationMilliseconds >= 0, nameof(hotKeys.CollectionDurationMilliseconds)); + Assert.Equal(HotKeysMetrics.Cpu | HotKeysMetrics.Network, hotKeys.Metrics); + Assert.True(hotKeys.CollectionDurationMicroseconds >= 0, nameof(hotKeys.CollectionDurationMicroseconds)); Assert.True(hotKeys.CollectionStartTimeUnixMilliseconds >= 0, nameof(hotKeys.CollectionStartTimeUnixMilliseconds)); Assert.Equal(1, hotKeys.CpuByKey.Length); @@ -149,10 +150,10 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser } Assert.True(hotKeys.TotalCpuTimeMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeMicroseconds)); - Assert.True(hotKeys.TotalCpuTimeSystemMilliseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMilliseconds)); - Assert.True(hotKeys.TotalCpuTimeUserMilliseconds >= 0, nameof(hotKeys.TotalCpuTimeUserMilliseconds)); + Assert.True(hotKeys.TotalCpuTimeSystemMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMicroseconds)); + Assert.True(hotKeys.TotalCpuTimeUserMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeUserMicroseconds)); Assert.True(hotKeys.TotalNetworkBytes > 0, nameof(hotKeys.TotalNetworkBytes)); - Assert.True(hotKeys.TotalNetworkBytes2 > 0, nameof(hotKeys.TotalNetworkBytes2)); + Assert.True(hotKeys.TotalProfiledNetworkBytes > 0, nameof(hotKeys.TotalProfiledNetworkBytes)); } [Fact] @@ -208,7 +209,29 @@ public async Task DurationFilterAsync() Assert.NotNull(after); Assert.False(after.TrackingActive); - Log($"Duration: {after.CollectionDurationMilliseconds}ms"); - Assert.True(after.CollectionDurationMilliseconds > 900 && after.CollectionDurationMilliseconds < 1100); + var millis = after.CollectionDuration.TotalMilliseconds; + Log($"Duration: {millis}ms"); + Assert.True(millis > 900 && millis < 1100); + } + + [Theory] + [InlineData(HotKeysMetrics.Cpu)] + [InlineData(HotKeysMetrics.Network)] + [InlineData(HotKeysMetrics.Network | HotKeysMetrics.Cpu)] + public async Task MetricsChoiceAsync(HotKeysMetrics metrics) + { + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + await server.HotKeysStartAsync(metrics); + var db = muxer.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + await db.StringIncrementAsync(key, flags: CommandFlags.FireAndForget); + } + await server.HotKeysStopAsync(flags: CommandFlags.FireAndForget); + var result = await server.HotKeysGetAsync(); + Assert.NotNull(result); + Assert.Equal(metrics, result.Metrics); } } From 9510dd44c9fe5f9f909a2be861c86925f8821563 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:34:06 +0000 Subject: [PATCH 14/27] make SharedAllSlots lazy; explicitly track empty cpu/network/slots --- .../ClusterConfiguration.cs | 4 +++- .../HotKeys.ResultProcessor.cs | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/StackExchange.Redis/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index 63e74a228..60e606ce2 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -46,7 +46,9 @@ private SlotRange(short from, short to) public int To => to; internal const int MinSlot = 0, MaxSlot = 16383; - internal static readonly SlotRange[] SharedAllSlots = [new(MinSlot, MaxSlot)]; + + private static SlotRange[]? s_SharedAllSlots; + internal static SlotRange[] SharedAllSlots => s_SharedAllSlots ??= [new(MinSlot, MaxSlot)]; /// /// Indicates whether two ranges are not equal. diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 91d276676..49768daf7 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -50,7 +50,11 @@ private HotKeysResult(in RawResult result) break; case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: var len = value.ItemsCount; - if (len == 0) continue; + if (len == 0) + { + _selectedSlots = []; + continue; + } var items = value.GetItems().GetEnumerator(); var slots = len == 1 ? null : new SlotRange[len]; @@ -125,7 +129,11 @@ private HotKeysResult(in RawResult result) case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: metrics |= HotKeysMetrics.Cpu; len = value.ItemsCount / 2; - if (len == 0) continue; + if (len == 0) + { + _cpuByKey = []; + continue; + } var cpuTime = new MetricKeyCpu[len]; items = value.GetItems().GetEnumerator(); @@ -143,7 +151,11 @@ private HotKeysResult(in RawResult result) case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: metrics |= HotKeysMetrics.Network; len = value.ItemsCount / 2; - if (len == 0) continue; + if (len == 0) + { + _networkBytesByKey = []; + continue; + } var netBytes = new MetricKeyBytes[len]; items = value.GetItems().GetEnumerator(); From 0646f15d0369a0319270ad00ce507a1658285344 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:37:15 +0000 Subject: [PATCH 15/27] More docs --- docs/HotKeys.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/HotKeys.md b/docs/HotKeys.md index 30f879c3c..6027279c7 100644 --- a/docs/HotKeys.md +++ b/docs/HotKeys.md @@ -7,7 +7,8 @@ This command is available via the `IServer.HotKeys*` methods: ``` c# // Get the server instance. -var server = muxer.GetServer(endpoint); +IConnectionMultiplexer muxer = ... // connect to Redis 8.6 or later +var server = muxer.GetServer(endpoint); // or muxer.GetServer(key) // Start the capture; you can specify a duration, or manually use the HotKeysStop[Async] method; specifying // a duration is recommended, so that the profiler will not be left running in the case of failure. @@ -50,3 +51,7 @@ If network metrics were captured, the following properties are also available: - `TotalProfiledNetworkBytes`: The total network data measured in the profiling session. - `NetworkBytesByKey`: Hot keys, as measured by network activity. + +Note: to use slot-based filtering, you must be connected to a Redis Cluster instance. The +`IConnectionMultiplexer.HashSlot(RedisKey)` method can be used to determine the slot for a given key. The key +can also be used in place of an endpoint when using `GetServer(...)` to get the `IServer` instance for a given key. From b392e89d87d7fba7745d95b55ce21c8cf63f31ef Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:38:15 +0000 Subject: [PATCH 16/27] "wow"? --- docs/HotKeys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/HotKeys.md b/docs/HotKeys.md index 6027279c7..5216f8058 100644 --- a/docs/HotKeys.md +++ b/docs/HotKeys.md @@ -16,7 +16,7 @@ var server = muxer.GetServer(endpoint); // or muxer.GetServer(key) // by default, all metrics are captured, every command is sampled, and all key slots are included. await server.HotKeysStartAsync(duration: TimeSpan.FromSeconds(30)); -// Wow either do some work ourselves, or await for some other activity to happen: +// Now either do some work ourselves, or await for some other activity to happen: await Task.Delay(TimeSpan.FromSeconds(35)); // whatever happens: happens // Fetch the results; note that this does not stop the capture, and you can fetch the results multiple times From e7f564b36ab42261b3236969ed19cb6c31c33258 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:42:41 +0000 Subject: [PATCH 17/27] more words --- docs/HotKeys.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/HotKeys.md b/docs/HotKeys.md index 5216f8058..37faa4059 100644 --- a/docs/HotKeys.md +++ b/docs/HotKeys.md @@ -45,12 +45,16 @@ If CPU metrics were captured, the following properties are also available: - `TotalProfiledCpuTimeUser`: The total user CPU time measured in the profiling session. - `TotalProfiledCpuTimeSystem`: The total system CPU time measured in the profiling session. - `TotalProfiledCpuTime`: The total CPU time measured in the profiling session. -- `CpuByKey`: Hot keys, as measured by CPU activity. +- `CpuByKey`: Hot keys, as measured by CPU activity; for each: + - `Key`: The key observed. + - `Duration`: The time taken. If network metrics were captured, the following properties are also available: - `TotalProfiledNetworkBytes`: The total network data measured in the profiling session. -- `NetworkBytesByKey`: Hot keys, as measured by network activity. +- `NetworkBytesByKey`: Hot keys, as measured by network activity; for each: + - `Key`: The key observed. + - `Bytes`: The network activity, in bytes. Note: to use slot-based filtering, you must be connected to a Redis Cluster instance. The `IConnectionMultiplexer.HashSlot(RedisKey)` method can be used to determine the slot for a given key. The key From bf3aa65f2085f809f66bd89e8c57dbf6679d823e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 10:49:00 +0000 Subject: [PATCH 18/27] update meaning of count --- src/StackExchange.Redis/HotKeys.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 84304fed2..fbad5c7e0 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -10,7 +10,7 @@ public partial interface IServer /// Start a new HOTKEYS profiling session. /// /// The metrics to record during this capture (defaults to "all"). - /// The total number of operations to profile. + /// The number of keys to retain and report when is invoked. If zero, the server default is used (currently 10). /// The duration of this profiling session. /// Profiling frequency; effectively: measure every Nth command. /// The key-slots to record during this capture (defaults to "all"). @@ -28,7 +28,7 @@ void HotKeysStart( /// Start a new HOTKEYS profiling session. /// /// The metrics to record during this capture (defaults to "all"). - /// The number of keys to retain and report when is invoked. + /// The number of keys to retain and report when is invoked. If zero, the server default is used (currently 10). /// The duration of this profiling session. /// Profiling frequency; effectively: measure every Nth command. /// The key-slots to record during this capture (defaults to "all" / "all on this node"). From 286e8c3ce51ca00c1155f58a26f6e21b541e6915 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 11:15:23 +0000 Subject: [PATCH 19/27] expose a bunch of values that are conditionally present --- docs/HotKeys.md | 14 ++++++-- .../HotKeys.ResultProcessor.cs | 16 +++++++++ src/StackExchange.Redis/HotKeys.cs | 33 +++++++++++++++++-- .../PublicAPI/PublicAPI.Unshipped.txt | 5 +++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/HotKeys.md b/docs/HotKeys.md index 37faa4059..76348a6ec 100644 --- a/docs/HotKeys.md +++ b/docs/HotKeys.md @@ -33,12 +33,22 @@ The `HotKeysResult` class (our `result` value above) contains the following prop - `Metrics`: The metrics captured during this profiling session. - `TrackingActive`: Indicates whether the capture currently active. -- `SampleRatio`: Profiling frequency; effectively: measure every Nth command. +- `SampleRatio`: Profiling frequency; effectively: measure every Nth command. (also: `IsSampled`) - `SelectedSlots`: The key slots active for this profiling session. - `CollectionStartTime`: The start time of the capture. - `CollectionDuration`: The duration of the capture. -- `TotalNetworkBytes`: The total network usage measured for all commands in all slots, without any sampling or filtering applied. - `TotalCpuTime`: The total CPU time measured for all commands in all slots, without any sampling or filtering applied. +- `TotalNetworkBytes`: The total network usage measured for all commands in all slots, without any sampling or filtering applied. + +When slot filtering is used, the following properties are also available: + +- `TotalSelectedSlotsCpuTime`: The total CPU time measured for all commands in the selected slots. +- `TotalSelectedSlotsNetworkBytes`: The total network usage measured for all commands in the selected slots. + +When slot filtering *and* sampling is used, the following properties are also available: + +- `TotalSampledSelectedSlotsCpuTime`: The total CPU time measured for the sampled commands in the selected slots. +- `TotalSampledSelectedSlotsNetworkBytes`: The total network usage measured for the sampled commands in the selected slots. If CPU metrics were captured, the following properties are also available: diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 49768daf7..08772a856 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -94,9 +94,21 @@ private HotKeysResult(in RawResult result) case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): TotalCpuTimeMicroseconds = i64; break; + case all_commands_selected_slots_us.Hash when all_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + TotalSelectedSlotsCpuTimeMicroseconds = i64; + break; + case sampled_command_selected_slots_us.Hash when sampled_command_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + TotalSampledSelectedSlotsCpuTimeMicroseconds = i64; + break; case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): TotalNetworkBytes = i64; break; + case net_bytes_all_commands_selected_slots.Hash when net_bytes_all_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): + TotalSelectedSlotsNetworkBytesRaw = i64; + break; + case net_bytes_sampled_commands_selected_slots.Hash when net_bytes_sampled_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): + TotalSampledSelectedSlotsNetworkBytesRaw = i64; + break; case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): CollectionStartTimeUnixMilliseconds = i64; break; @@ -181,7 +193,11 @@ [FastHash] internal static partial class tracking_active { } [FastHash] internal static partial class sample_ratio { } [FastHash] internal static partial class selected_slots { } [FastHash] internal static partial class all_commands_all_slots_us { } + [FastHash] internal static partial class all_commands_selected_slots_us { } + [FastHash] internal static partial class sampled_command_selected_slots_us { } [FastHash] internal static partial class net_bytes_all_commands_all_slots { } + [FastHash] internal static partial class net_bytes_all_commands_selected_slots { } + [FastHash] internal static partial class net_bytes_sampled_commands_selected_slots { } [FastHash] internal static partial class collection_start_time_unix_ms { } [FastHash] internal static partial class collection_duration_ms { } [FastHash] internal static partial class collection_duration_us { } diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index fbad5c7e0..b64565c9c 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -131,6 +131,11 @@ public sealed partial class HotKeysResult /// public long SampleRatio { get; } + /// + /// Gets whether sampling is in use. + /// + public bool IsSampled => SampleRatio > 1; + /// /// The key slots active for this profiling session. /// @@ -142,6 +147,20 @@ public sealed partial class HotKeysResult /// The total CPU measured for all commands in all slots, without any sampling or filtering applied. /// public TimeSpan TotalCpuTime => NonNegativeMicroseconds(TotalCpuTimeMicroseconds); + internal long TotalCpuTimeMicroseconds { get; } = -1; + + internal long TotalSelectedSlotsCpuTimeMicroseconds { get; } = -1; + internal long TotalSampledSelectedSlotsCpuTimeMicroseconds { get; } = -1; + + /// + /// When slot filtering is used, this is the total CPU time measured for all commands in the selected slots. Otherwise: . + /// + public TimeSpan TotalSelectedSlotsCpuTime => TotalSelectedSlotsCpuTimeMicroseconds >= 0 ? NonNegativeMicroseconds(TotalSelectedSlotsCpuTimeMicroseconds) : TotalCpuTime; + + /// + /// When sampling and slot filtering are used, this is the total CPU time measured for the sampled commands in the selected slots. Otherwise: . + /// + public TimeSpan TotalSampledSelectedSlotsCpuTime => TotalSampledSelectedSlotsCpuTimeMicroseconds >= 0 ? NonNegativeMicroseconds(TotalSampledSelectedSlotsCpuTimeMicroseconds) : TotalCpuTime; private static TimeSpan NonNegativeMicroseconds(long us) { @@ -149,12 +168,22 @@ private static TimeSpan NonNegativeMicroseconds(long us) return TimeSpan.FromTicks(Math.Max(us, 0) / TICKS_PER_MICROSECOND); } - internal long TotalCpuTimeMicroseconds { get; } = -1; - /// /// The total network usage measured for all commands in all slots, without any sampling or filtering applied. /// public long TotalNetworkBytes { get; } + internal long TotalSelectedSlotsNetworkBytesRaw { get; } = -1; + internal long TotalSampledSelectedSlotsNetworkBytesRaw { get; } = -1; + + /// + /// When slot filtering is used, this is the total network usage measured for all commands in the selected slots. Otherwise: . + /// + public long TotalSelectedSlotsNetworkBytes => TotalSelectedSlotsNetworkBytesRaw >= 0 ? TotalSelectedSlotsNetworkBytesRaw : TotalNetworkBytes; + + /// + /// When sampling and slot filtering are used, this is the total network usage measured for the sampled commands in the selected slots. Otherwise: . + /// + public long TotalSampledSelectedSlotsNetworkBytes => TotalSampledSelectedSlotsNetworkBytesRaw >= 0 ? TotalSampledSelectedSlotsNetworkBytesRaw : TotalNetworkBytes; internal long CollectionStartTimeUnixMilliseconds { get; } = -1; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 075d1fbae..dd79d9f16 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -7,6 +7,7 @@ [SER003]StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan [SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime [SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> System.ReadOnlySpan +[SER003]StackExchange.Redis.HotKeysResult.IsSampled.get -> bool [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey @@ -27,6 +28,10 @@ [SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeSystem.get -> System.TimeSpan [SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeUser.get -> System.TimeSpan [SER003]StackExchange.Redis.HotKeysResult.TotalProfiledNetworkBytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TotalSampledSelectedSlotsCpuTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalSampledSelectedSlotsNetworkBytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TotalSelectedSlotsCpuTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.TotalSelectedSlotsNetworkBytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool [SER003]StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? [SER003]StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! From 96a16042c1201f714ac1b7e99c3fa52c9b480cb5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 11:29:11 +0000 Subject: [PATCH 20/27] tests on the sampled/slot-filtered metrics --- src/StackExchange.Redis/HotKeys.cs | 5 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../StackExchange.Redis.Tests/HotKeysTests.cs | 50 +++++++++++++++++-- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index b64565c9c..be8e314fc 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -143,6 +143,11 @@ public sealed partial class HotKeysResult private readonly SlotRange[]? _selectedSlots; + /// + /// Gets whether slot filtering is in use. + /// + public bool IsSlotFiltered => TotalSelectedSlotsNetworkBytesRaw >= 0; // this key only present if slot-filtering active + /// /// The total CPU measured for all commands in all slots, without any sampling or filtering applied. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index dd79d9f16..ecdef161f 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -8,6 +8,7 @@ [SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime [SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> System.ReadOnlySpan [SER003]StackExchange.Redis.HotKeysResult.IsSampled.get -> bool +[SER003]StackExchange.Redis.HotKeysResult.IsSlotFiltered.get -> bool [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Bytes.get -> long [SER003]StackExchange.Redis.HotKeysResult.MetricKeyBytes.Key.get -> StackExchange.Redis.RedisKey diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index 07201b8e7..d45a0ee72 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -8,15 +8,17 @@ public class HotKeysClusterTests(ITestOutputHelper output, SharedConnectionFixtu { protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; - [Fact] - public void CanUseClusterFilter() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanUseClusterFilter(bool sample) { var key = Me(); using var muxer = GetServer(key, out var server); Log($"server: {Format.ToString(server.EndPoint)}, key: '{key}'"); var slot = muxer.HashSlot(key); - server.HotKeysStart(slots: [(short)slot]); + server.HotKeysStart(slots: [(short)slot], sampleRatio: sample ? 3 : 1); var db = muxer.GetDatabase(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); @@ -28,6 +30,7 @@ public void CanUseClusterFilter() server.HotKeysStop(); var result = server.HotKeysGet(); Assert.NotNull(result); + Assert.True(result.IsSlotFiltered, nameof(result.IsSlotFiltered)); var slots = result.SelectedSlots; Assert.Equal(1, slots.Length); Assert.Equal(slot, slots[0].From); @@ -38,6 +41,23 @@ public void CanUseClusterFilter() Assert.Equal(1, result.NetworkBytesByKey.Length); Assert.Equal(key, result.NetworkBytesByKey[0].Key); + + Assert.True(result.TotalSelectedSlotsCpuTimeMicroseconds >= 0, nameof(result.TotalSelectedSlotsCpuTimeMicroseconds)); + Assert.True(result.TotalCpuTimeUserMicroseconds >= 0, nameof(result.TotalCpuTimeUserMicroseconds)); + + Assert.Equal(sample, result.IsSampled); + if (sample) + { + Assert.Equal(3, result.SampleRatio); + Assert.True(result.TotalSampledSelectedSlotsCpuTimeMicroseconds >= 0, nameof(result.TotalSampledSelectedSlotsCpuTimeMicroseconds)); + Assert.True(result.TotalSampledSelectedSlotsNetworkBytesRaw >= 0, nameof(result.TotalSampledSelectedSlotsNetworkBytesRaw)); + } + else + { + Assert.Equal(1, result.SampleRatio); + Assert.Equal(-1, result.TotalSampledSelectedSlotsCpuTimeMicroseconds); + Assert.Equal(-1, result.TotalSampledSelectedSlotsNetworkBytesRaw); + } } } @@ -131,6 +151,8 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser Assert.True(net.Bytes > 0, nameof(net.Bytes)); Assert.Equal(1, hotKeys.SampleRatio); + Assert.False(hotKeys.IsSampled, nameof(hotKeys.IsSampled)); + Assert.False(hotKeys.IsSlotFiltered, nameof(hotKeys.IsSlotFiltered)); if (server.ServerType is ServerType.Cluster) { @@ -234,4 +256,26 @@ public async Task MetricsChoiceAsync(HotKeysMetrics metrics) Assert.NotNull(result); Assert.Equal(metrics, result.Metrics); } + + [Fact] + public async Task SampleRatioUsageAsync() + { + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + await server.HotKeysStartAsync(sampleRatio: 3); + var db = muxer.GetDatabase(); + await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); + for (int i = 0; i < 20; i++) + { + await db.StringIncrementAsync(key, flags: CommandFlags.FireAndForget); + } + + await server.HotKeysStopAsync(flags: CommandFlags.FireAndForget); + var result = await server.HotKeysGetAsync(); + Assert.NotNull(result); + Assert.True(result.IsSampled, nameof(result.IsSampled)); + Assert.Equal(3, result.SampleRatio); + Assert.NotEqual(result.TotalProfiledNetworkBytes, result.TotalNetworkBytes); + Assert.NotEqual(result.TotalProfiledCpuTime, result.TotalCpuTime); + } } From 2420039c9f5dbb22f8fcf8a3ff24f3f25e433634 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 13:29:55 +0000 Subject: [PATCH 21/27] - naming in HotKeysResult - prefer Nullable when not-always-present --- docs/HotKeys.md | 20 +++--- .../HotKeys.ResultProcessor.cs | 14 ++-- src/StackExchange.Redis/HotKeys.cs | 72 +++++++++++++------ .../PublicAPI/PublicAPI.Unshipped.txt | 20 +++--- .../StackExchange.Redis.Tests/HotKeysTests.cs | 32 +++++---- 5 files changed, 93 insertions(+), 65 deletions(-) diff --git a/docs/HotKeys.md b/docs/HotKeys.md index 76348a6ec..5ac7c86f9 100644 --- a/docs/HotKeys.md +++ b/docs/HotKeys.md @@ -37,31 +37,31 @@ The `HotKeysResult` class (our `result` value above) contains the following prop - `SelectedSlots`: The key slots active for this profiling session. - `CollectionStartTime`: The start time of the capture. - `CollectionDuration`: The duration of the capture. -- `TotalCpuTime`: The total CPU time measured for all commands in all slots, without any sampling or filtering applied. -- `TotalNetworkBytes`: The total network usage measured for all commands in all slots, without any sampling or filtering applied. +- `AllCommandsAllSlotsTime`: The total CPU time measured for all commands in all slots, without any sampling or filtering applied. +- `AllCommandsAllSlotsNetworkBytes`: The total network usage measured for all commands in all slots, without any sampling or filtering applied. When slot filtering is used, the following properties are also available: -- `TotalSelectedSlotsCpuTime`: The total CPU time measured for all commands in the selected slots. -- `TotalSelectedSlotsNetworkBytes`: The total network usage measured for all commands in the selected slots. +- `AllCommandsSelectedSlotsTime`: The total CPU time measured for all commands in the selected slots. +- `AllCommandsSelectedSlotsNetworkBytes`: The total network usage measured for all commands in the selected slots. When slot filtering *and* sampling is used, the following properties are also available: -- `TotalSampledSelectedSlotsCpuTime`: The total CPU time measured for the sampled commands in the selected slots. -- `TotalSampledSelectedSlotsNetworkBytes`: The total network usage measured for the sampled commands in the selected slots. +- `SampledCommandsSelectedSlotsTime`: The total CPU time measured for the sampled commands in the selected slots. +- `SampledCommandsSelectedSlotsNetworkBytes`: The total network usage measured for the sampled commands in the selected slots. If CPU metrics were captured, the following properties are also available: -- `TotalProfiledCpuTimeUser`: The total user CPU time measured in the profiling session. -- `TotalProfiledCpuTimeSystem`: The total system CPU time measured in the profiling session. -- `TotalProfiledCpuTime`: The total CPU time measured in the profiling session. +- `TotalCpuTimeUser`: The total user CPU time measured in the profiling session. +- `TotalCpuTimeSystem`: The total system CPU time measured in the profiling session. +- `TotalCpuTime`: The total CPU time measured in the profiling session. - `CpuByKey`: Hot keys, as measured by CPU activity; for each: - `Key`: The key observed. - `Duration`: The time taken. If network metrics were captured, the following properties are also available: -- `TotalProfiledNetworkBytes`: The total network data measured in the profiling session. +- `TotalNetworkBytes`: The total network data measured in the profiling session. - `NetworkBytesByKey`: Hot keys, as measured by network activity; for each: - `Key`: The key observed. - `Bytes`: The network activity, in bytes. diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 08772a856..3d355de77 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -92,22 +92,22 @@ private HotKeysResult(in RawResult result) _selectedSlots = slots; break; case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): - TotalCpuTimeMicroseconds = i64; + AllCommandsAllSlotsMicroseconds = i64; break; case all_commands_selected_slots_us.Hash when all_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): - TotalSelectedSlotsCpuTimeMicroseconds = i64; + AllCommandSelectedSlotsMicroseconds = i64; break; case sampled_command_selected_slots_us.Hash when sampled_command_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): - TotalSampledSelectedSlotsCpuTimeMicroseconds = i64; + SampledCommandsSelectedSlotsMicroseconds = i64; break; case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): - TotalNetworkBytes = i64; + AllCommandsAllSlotsNetworkBytes = i64; break; case net_bytes_all_commands_selected_slots.Hash when net_bytes_all_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): - TotalSelectedSlotsNetworkBytesRaw = i64; + NetworkBytesAllCommandsSelectedSlotsRaw = i64; break; case net_bytes_sampled_commands_selected_slots.Hash when net_bytes_sampled_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): - TotalSampledSelectedSlotsNetworkBytesRaw = i64; + NetworkBytesSampledCommandsSelectedSlotsRaw = i64; break; case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): CollectionStartTimeUnixMilliseconds = i64; @@ -136,7 +136,7 @@ private HotKeysResult(in RawResult result) break; case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out var i64): metrics |= HotKeysMetrics.Network; - TotalProfiledNetworkBytes = i64; + TotalNetworkBytesRaw = i64; break; case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: metrics |= HotKeysMetrics.Cpu; diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index be8e314fc..270bcf9f7 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -116,6 +116,11 @@ public enum HotKeysMetrics [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] public sealed partial class HotKeysResult { + // Note: names are intentionally chosen to align reasonably well with the Redis command output; some + // liberties have been taken, for example "all-commands-all-slots-us" and "net-bytes-all-commands-all-slots" + // have been named "AllCommandsAllSlotsTime" and "AllCommandsAllSlotsNetworkBytes" for consistency + // with each-other. + /// /// The metrics captured during this profiling session. /// @@ -146,26 +151,32 @@ public sealed partial class HotKeysResult /// /// Gets whether slot filtering is in use. /// - public bool IsSlotFiltered => TotalSelectedSlotsNetworkBytesRaw >= 0; // this key only present if slot-filtering active + public bool IsSlotFiltered => + NetworkBytesAllCommandsSelectedSlotsRaw >= 0; // this key only present if slot-filtering active /// /// The total CPU measured for all commands in all slots, without any sampling or filtering applied. /// - public TimeSpan TotalCpuTime => NonNegativeMicroseconds(TotalCpuTimeMicroseconds); - internal long TotalCpuTimeMicroseconds { get; } = -1; + public TimeSpan AllCommandsAllSlotsTime => NonNegativeMicroseconds(AllCommandsAllSlotsMicroseconds); + + internal long AllCommandsAllSlotsMicroseconds { get; } = -1; - internal long TotalSelectedSlotsCpuTimeMicroseconds { get; } = -1; - internal long TotalSampledSelectedSlotsCpuTimeMicroseconds { get; } = -1; + internal long AllCommandSelectedSlotsMicroseconds { get; } = -1; + internal long SampledCommandsSelectedSlotsMicroseconds { get; } = -1; /// - /// When slot filtering is used, this is the total CPU time measured for all commands in the selected slots. Otherwise: . + /// When slot filtering is used, this is the total CPU time measured for all commands in the selected slots. /// - public TimeSpan TotalSelectedSlotsCpuTime => TotalSelectedSlotsCpuTimeMicroseconds >= 0 ? NonNegativeMicroseconds(TotalSelectedSlotsCpuTimeMicroseconds) : TotalCpuTime; + public TimeSpan? AllCommandsSelectedSlotsTime => AllCommandSelectedSlotsMicroseconds < 0 + ? null + : NonNegativeMicroseconds(AllCommandSelectedSlotsMicroseconds); /// - /// When sampling and slot filtering are used, this is the total CPU time measured for the sampled commands in the selected slots. Otherwise: . + /// When sampling and slot filtering are used, this is the total CPU time measured for the sampled commands in the selected slots. /// - public TimeSpan TotalSampledSelectedSlotsCpuTime => TotalSampledSelectedSlotsCpuTimeMicroseconds >= 0 ? NonNegativeMicroseconds(TotalSampledSelectedSlotsCpuTimeMicroseconds) : TotalCpuTime; + public TimeSpan? SampledCommandsSelectedSlotsTime => SampledCommandsSelectedSlotsMicroseconds < 0 + ? null + : NonNegativeMicroseconds(SampledCommandsSelectedSlotsMicroseconds); private static TimeSpan NonNegativeMicroseconds(long us) { @@ -176,26 +187,32 @@ private static TimeSpan NonNegativeMicroseconds(long us) /// /// The total network usage measured for all commands in all slots, without any sampling or filtering applied. /// - public long TotalNetworkBytes { get; } - internal long TotalSelectedSlotsNetworkBytesRaw { get; } = -1; - internal long TotalSampledSelectedSlotsNetworkBytesRaw { get; } = -1; + public long AllCommandsAllSlotsNetworkBytes { get; } + + internal long NetworkBytesAllCommandsSelectedSlotsRaw { get; } = -1; + internal long NetworkBytesSampledCommandsSelectedSlotsRaw { get; } = -1; /// - /// When slot filtering is used, this is the total network usage measured for all commands in the selected slots. Otherwise: . + /// When slot filtering is used, this is the total network usage measured for all commands in the selected slots. /// - public long TotalSelectedSlotsNetworkBytes => TotalSelectedSlotsNetworkBytesRaw >= 0 ? TotalSelectedSlotsNetworkBytesRaw : TotalNetworkBytes; + public long? AllCommandsSelectedSlotsNetworkBytes => NetworkBytesAllCommandsSelectedSlotsRaw < 0 + ? null + : NetworkBytesAllCommandsSelectedSlotsRaw; /// - /// When sampling and slot filtering are used, this is the total network usage measured for the sampled commands in the selected slots. Otherwise: . + /// When sampling and slot filtering are used, this is the total network usage measured for the sampled commands in the selected slots. /// - public long TotalSampledSelectedSlotsNetworkBytes => TotalSampledSelectedSlotsNetworkBytesRaw >= 0 ? TotalSampledSelectedSlotsNetworkBytesRaw : TotalNetworkBytes; + public long? SampledCommandsSelectedSlotsNetworkBytes => NetworkBytesSampledCommandsSelectedSlotsRaw < 0 + ? null + : NetworkBytesSampledCommandsSelectedSlotsRaw; internal long CollectionStartTimeUnixMilliseconds { get; } = -1; /// /// The start time of the capture. /// - public DateTime CollectionStartTime => RedisBase.UnixEpoch.AddMilliseconds(Math.Max(CollectionStartTimeUnixMilliseconds, 0)); + public DateTime CollectionStartTime => + RedisBase.UnixEpoch.AddMilliseconds(Math.Max(CollectionStartTimeUnixMilliseconds, 0)); internal long CollectionDurationMicroseconds { get; } @@ -209,24 +226,32 @@ private static TimeSpan NonNegativeMicroseconds(long us) /// /// The total user CPU time measured in the profiling session. /// - public TimeSpan TotalProfiledCpuTimeUser => NonNegativeMicroseconds(TotalCpuTimeUserMicroseconds); + public TimeSpan? TotalCpuTimeUser => TotalCpuTimeUserMicroseconds < 0 + ? null + : NonNegativeMicroseconds(TotalCpuTimeUserMicroseconds); internal long TotalCpuTimeSystemMicroseconds { get; } = -1; /// /// The total system CPU measured in the profiling session. /// - public TimeSpan TotalProfiledCpuTimeSystem => NonNegativeMicroseconds(TotalCpuTimeSystemMicroseconds); + public TimeSpan? TotalCpuTimeSystem => TotalCpuTimeSystemMicroseconds < 0 + ? null + : NonNegativeMicroseconds(TotalCpuTimeSystemMicroseconds); /// - /// The total CPU time measured in the profiling session (this is just + ). + /// The total CPU time measured in the profiling session (this is just + ). /// - public TimeSpan TotalProfiledCpuTime => TotalProfiledCpuTimeUser + TotalProfiledCpuTimeSystem; + public TimeSpan? TotalCpuTime => TotalCpuTimeUser + TotalCpuTimeSystem; + + internal long TotalNetworkBytesRaw { get; } = -1; /// /// The total network data measured in the profiling session. /// - public long TotalProfiledNetworkBytes { get; } + public long? TotalNetworkBytes => TotalNetworkBytesRaw < 0 + ? null + : TotalNetworkBytesRaw; // Intentionally do construct a dictionary from the results; the caller is unlikely to be looking // for a particular key (lookup), but rather: is likely to want to list them for display; this way, @@ -275,7 +300,8 @@ public readonly struct MetricKeyCpu(in RedisKey key, long durationMicroseconds) /// public override bool Equals(object? obj) - => obj is MetricKeyCpu other && _key.Equals(other.Key) && durationMicroseconds == other.DurationMicroseconds; + => obj is MetricKeyCpu other && _key.Equals(other.Key) && + durationMicroseconds == other.DurationMicroseconds; } /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ecdef161f..5b1daa9e9 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -4,6 +4,10 @@ [SER003]StackExchange.Redis.HotKeysMetrics.Network = 2 -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysMetrics.None = 0 -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysResult +[SER003]StackExchange.Redis.HotKeysResult.AllCommandsAllSlotsNetworkBytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.AllCommandsAllSlotsTime.get -> System.TimeSpan +[SER003]StackExchange.Redis.HotKeysResult.AllCommandsSelectedSlotsNetworkBytes.get -> long? +[SER003]StackExchange.Redis.HotKeysResult.AllCommandsSelectedSlotsTime.get -> System.TimeSpan? [SER003]StackExchange.Redis.HotKeysResult.CollectionDuration.get -> System.TimeSpan [SER003]StackExchange.Redis.HotKeysResult.CollectionStartTime.get -> System.DateTime [SER003]StackExchange.Redis.HotKeysResult.CpuByKey.get -> System.ReadOnlySpan @@ -21,18 +25,14 @@ [SER003]StackExchange.Redis.HotKeysResult.MetricKeyCpu.MetricKeyCpu(in StackExchange.Redis.RedisKey key, long durationMicroseconds) -> void [SER003]StackExchange.Redis.HotKeysResult.Metrics.get -> StackExchange.Redis.HotKeysMetrics [SER003]StackExchange.Redis.HotKeysResult.NetworkBytesByKey.get -> System.ReadOnlySpan +[SER003]StackExchange.Redis.HotKeysResult.SampledCommandsSelectedSlotsNetworkBytes.get -> long? +[SER003]StackExchange.Redis.HotKeysResult.SampledCommandsSelectedSlotsTime.get -> System.TimeSpan? [SER003]StackExchange.Redis.HotKeysResult.SampleRatio.get -> long [SER003]StackExchange.Redis.HotKeysResult.SelectedSlots.get -> System.ReadOnlySpan -[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTime.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeSystem.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledCpuTimeUser.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalProfiledNetworkBytes.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalSampledSelectedSlotsCpuTime.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalSampledSelectedSlotsNetworkBytes.get -> long -[SER003]StackExchange.Redis.HotKeysResult.TotalSelectedSlotsCpuTime.get -> System.TimeSpan -[SER003]StackExchange.Redis.HotKeysResult.TotalSelectedSlotsNetworkBytes.get -> long +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTime.get -> System.TimeSpan? +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeSystem.get -> System.TimeSpan? +[SER003]StackExchange.Redis.HotKeysResult.TotalCpuTimeUser.get -> System.TimeSpan? +[SER003]StackExchange.Redis.HotKeysResult.TotalNetworkBytes.get -> long? [SER003]StackExchange.Redis.HotKeysResult.TrackingActive.get -> bool [SER003]StackExchange.Redis.IServer.HotKeysGet(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.HotKeysResult? [SER003]StackExchange.Redis.IServer.HotKeysGetAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index d45a0ee72..adb73fd41 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -18,7 +18,7 @@ public void CanUseClusterFilter(bool sample) Log($"server: {Format.ToString(server.EndPoint)}, key: '{key}'"); var slot = muxer.HashSlot(key); - server.HotKeysStart(slots: [(short)slot], sampleRatio: sample ? 3 : 1); + server.HotKeysStart(slots: [(short)slot], sampleRatio: sample ? 3 : 1, duration: Duration); var db = muxer.GetDatabase(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); @@ -42,21 +42,21 @@ public void CanUseClusterFilter(bool sample) Assert.Equal(1, result.NetworkBytesByKey.Length); Assert.Equal(key, result.NetworkBytesByKey[0].Key); - Assert.True(result.TotalSelectedSlotsCpuTimeMicroseconds >= 0, nameof(result.TotalSelectedSlotsCpuTimeMicroseconds)); + Assert.True(result.AllCommandSelectedSlotsMicroseconds >= 0, nameof(result.AllCommandSelectedSlotsMicroseconds)); Assert.True(result.TotalCpuTimeUserMicroseconds >= 0, nameof(result.TotalCpuTimeUserMicroseconds)); Assert.Equal(sample, result.IsSampled); if (sample) { Assert.Equal(3, result.SampleRatio); - Assert.True(result.TotalSampledSelectedSlotsCpuTimeMicroseconds >= 0, nameof(result.TotalSampledSelectedSlotsCpuTimeMicroseconds)); - Assert.True(result.TotalSampledSelectedSlotsNetworkBytesRaw >= 0, nameof(result.TotalSampledSelectedSlotsNetworkBytesRaw)); + Assert.True(result.SampledCommandsSelectedSlotsMicroseconds >= 0, nameof(result.SampledCommandsSelectedSlotsMicroseconds)); + Assert.True(result.NetworkBytesSampledCommandsSelectedSlotsRaw >= 0, nameof(result.NetworkBytesSampledCommandsSelectedSlotsRaw)); } else { Assert.Equal(1, result.SampleRatio); - Assert.Equal(-1, result.TotalSampledSelectedSlotsCpuTimeMicroseconds); - Assert.Equal(-1, result.TotalSampledSelectedSlotsNetworkBytesRaw); + Assert.Equal(-1, result.SampledCommandsSelectedSlotsMicroseconds); + Assert.Equal(-1, result.NetworkBytesSampledCommandsSelectedSlotsRaw); } } } @@ -65,6 +65,8 @@ public void CanUseClusterFilter(bool sample) [Collection(NonParallelCollection.Name)] public class HotKeysTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) { + protected TimeSpan Duration => TimeSpan.FromMinutes(1); // ensure we don't leave profiling running + private protected IConnectionMultiplexer GetServer(out IServer server) => GetServer(RedisKey.Null, out server); @@ -110,7 +112,7 @@ public void CanStartStopReset() { RedisKey key = Me(); using var muxer = GetServer(key, out var server); - server.HotKeysStart(); + server.HotKeysStart(duration: Duration); var db = muxer.GetDatabase(); db.KeyDelete(key, flags: CommandFlags.FireAndForget); for (int i = 0; i < 20; i++) @@ -171,11 +173,11 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser Assert.Equal(SlotRange.MaxSlot, slots.To); } - Assert.True(hotKeys.TotalCpuTimeMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeMicroseconds)); + Assert.True(hotKeys.AllCommandsAllSlotsMicroseconds >= 0, nameof(hotKeys.AllCommandsAllSlotsMicroseconds)); Assert.True(hotKeys.TotalCpuTimeSystemMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMicroseconds)); Assert.True(hotKeys.TotalCpuTimeUserMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeUserMicroseconds)); - Assert.True(hotKeys.TotalNetworkBytes > 0, nameof(hotKeys.TotalNetworkBytes)); - Assert.True(hotKeys.TotalProfiledNetworkBytes > 0, nameof(hotKeys.TotalProfiledNetworkBytes)); + Assert.True(hotKeys.AllCommandsAllSlotsNetworkBytes > 0, nameof(hotKeys.AllCommandsAllSlotsNetworkBytes)); + Assert.True(hotKeys.TotalNetworkBytes > 0, nameof(hotKeys.TotalNetworkBytes)); } [Fact] @@ -183,7 +185,7 @@ public async Task CanStartStopResetAsync() { RedisKey key = Me(); await using var muxer = GetServer(key, out var server); - await server.HotKeysStartAsync(); + await server.HotKeysStartAsync(duration: Duration); var db = muxer.GetDatabase(); await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); for (int i = 0; i < 20; i++) @@ -244,7 +246,7 @@ public async Task MetricsChoiceAsync(HotKeysMetrics metrics) { RedisKey key = Me(); await using var muxer = GetServer(key, out var server); - await server.HotKeysStartAsync(metrics); + await server.HotKeysStartAsync(metrics, duration: Duration); var db = muxer.GetDatabase(); await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); for (int i = 0; i < 20; i++) @@ -262,7 +264,7 @@ public async Task SampleRatioUsageAsync() { RedisKey key = Me(); await using var muxer = GetServer(key, out var server); - await server.HotKeysStartAsync(sampleRatio: 3); + await server.HotKeysStartAsync(sampleRatio: 3, duration: Duration); var db = muxer.GetDatabase(); await db.KeyDeleteAsync(key, flags: CommandFlags.FireAndForget); for (int i = 0; i < 20; i++) @@ -275,7 +277,7 @@ public async Task SampleRatioUsageAsync() Assert.NotNull(result); Assert.True(result.IsSampled, nameof(result.IsSampled)); Assert.Equal(3, result.SampleRatio); - Assert.NotEqual(result.TotalProfiledNetworkBytes, result.TotalNetworkBytes); - Assert.NotEqual(result.TotalProfiledCpuTime, result.TotalCpuTime); + Assert.True(result.TotalNetworkBytes.HasValue); + Assert.True(result.TotalCpuTime.HasValue); } } From 95892a68fc400ecadd0936929965238ff62b8f7f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 13:33:21 +0000 Subject: [PATCH 22/27] pre-empt typo fix --- .../HotKeys.ResultProcessor.cs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 3d355de77..a0f5b2892 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -40,12 +40,13 @@ private HotKeysResult(in RawResult result) if (!iter.MoveNext()) break; // lies about the length! ref readonly RawResult value = ref iter.Current; var hash = key.Payload.Hash64(); + long i64; switch (hash) { case tracking_active.Hash when tracking_active.Is(hash, key): TrackingActive = value.GetBoolean(); break; - case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out var i64): + case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out i64): SampleRatio = i64; break; case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: @@ -91,50 +92,51 @@ private HotKeysResult(in RawResult result) } _selectedSlots = slots; break; - case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out i64): AllCommandsAllSlotsMicroseconds = i64; break; - case all_commands_selected_slots_us.Hash when all_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + case all_commands_selected_slots_us.Hash when all_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): AllCommandSelectedSlotsMicroseconds = i64; break; - case sampled_command_selected_slots_us.Hash when sampled_command_selected_slots_us.Is(hash, key) && value.TryGetInt64(out var i64): + case sampled_command_selected_slots_us.Hash when sampled_command_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): + case sampled_commands_selected_slots_us.Hash when sampled_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): SampledCommandsSelectedSlotsMicroseconds = i64; break; - case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out var i64): + case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out i64): AllCommandsAllSlotsNetworkBytes = i64; break; - case net_bytes_all_commands_selected_slots.Hash when net_bytes_all_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): + case net_bytes_all_commands_selected_slots.Hash when net_bytes_all_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out i64): NetworkBytesAllCommandsSelectedSlotsRaw = i64; break; - case net_bytes_sampled_commands_selected_slots.Hash when net_bytes_sampled_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out var i64): + case net_bytes_sampled_commands_selected_slots.Hash when net_bytes_sampled_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out i64): NetworkBytesSampledCommandsSelectedSlotsRaw = i64; break; - case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out var i64): + case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out i64): CollectionStartTimeUnixMilliseconds = i64; break; - case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out var i64): + case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out i64): CollectionDurationMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case collection_duration_us.Hash when collection_duration_us.Is(hash, key) && value.TryGetInt64(out var i64): + case collection_duration_us.Hash when collection_duration_us.Is(hash, key) && value.TryGetInt64(out i64): CollectionDurationMicroseconds = i64; break; - case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out var i64): + case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case total_cpu_time_sys_us.Hash when total_cpu_time_sys_us.Is(hash, key) && value.TryGetInt64(out var i64): + case total_cpu_time_sys_us.Hash when total_cpu_time_sys_us.Is(hash, key) && value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64; break; - case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out var i64): + case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case total_cpu_time_user_us.Hash when total_cpu_time_user_us.Is(hash, key) && value.TryGetInt64(out var i64): + case total_cpu_time_user_us.Hash when total_cpu_time_user_us.Is(hash, key) && value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64; break; - case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out var i64): + case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Network; TotalNetworkBytesRaw = i64; break; @@ -195,6 +197,7 @@ [FastHash] internal static partial class selected_slots { } [FastHash] internal static partial class all_commands_all_slots_us { } [FastHash] internal static partial class all_commands_selected_slots_us { } [FastHash] internal static partial class sampled_command_selected_slots_us { } + [FastHash] internal static partial class sampled_commands_selected_slots_us { } [FastHash] internal static partial class net_bytes_all_commands_all_slots { } [FastHash] internal static partial class net_bytes_all_commands_selected_slots { } [FastHash] internal static partial class net_bytes_sampled_commands_selected_slots { } From 1d8da60c8e5967a78da5f1ff543ecd1820f2c61d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 9 Feb 2026 14:07:06 +0000 Subject: [PATCH 23/27] CI: use internal 8.6 preview build --- tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- tests/StackExchange.Redis.Tests/HotKeysTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index b26ab5d76..9af6ee8cc 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -FROM redislabs/client-libs-test:8.4-GA-pre.3 +FROM redislabs/client-libs-test:custom-21651605017-debian-amd64 COPY --from=configs ./Basic /data/Basic/ COPY --from=configs ./Failover /data/Failover/ diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index adb73fd41..8d2205ea3 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -72,7 +72,7 @@ private protected IConnectionMultiplexer GetServer(out IServer server) private protected IConnectionMultiplexer GetServer(in RedisKey key, out IServer server) { - var muxer = Create(require: RedisFeatures.v8_4_0_rc1, allowAdmin: true); // TODO: 8.6 + var muxer = Create(require: RedisFeatures.v8_6_0, allowAdmin: true); server = key.IsNull ? muxer.GetServer(muxer.GetEndPoints()[0]) : muxer.GetServer(key); server.HotKeysStop(CommandFlags.FireAndForget); server.HotKeysReset(CommandFlags.FireAndForget); From 7b5252fd06e444960f7abcf0165498325c8cf99e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Feb 2026 11:03:12 +0000 Subject: [PATCH 24/27] additional validation on conditional members --- tests/StackExchange.Redis.Tests/HotKeysTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index 8d2205ea3..ec22c4f0b 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -51,13 +51,20 @@ public void CanUseClusterFilter(bool sample) Assert.Equal(3, result.SampleRatio); Assert.True(result.SampledCommandsSelectedSlotsMicroseconds >= 0, nameof(result.SampledCommandsSelectedSlotsMicroseconds)); Assert.True(result.NetworkBytesSampledCommandsSelectedSlotsRaw >= 0, nameof(result.NetworkBytesSampledCommandsSelectedSlotsRaw)); + Assert.True(result.SampledCommandsSelectedSlotsTime.HasValue); + Assert.True(result.SampledCommandsSelectedSlotsNetworkBytes.HasValue); } else { Assert.Equal(1, result.SampleRatio); Assert.Equal(-1, result.SampledCommandsSelectedSlotsMicroseconds); Assert.Equal(-1, result.NetworkBytesSampledCommandsSelectedSlotsRaw); + Assert.False(result.SampledCommandsSelectedSlotsTime.HasValue); + Assert.False(result.SampledCommandsSelectedSlotsNetworkBytes.HasValue); } + + Assert.True(result.AllCommandsSelectedSlotsTime.HasValue); + Assert.True(result.AllCommandsSelectedSlotsNetworkBytes.HasValue); } } @@ -178,6 +185,11 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser Assert.True(hotKeys.TotalCpuTimeUserMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeUserMicroseconds)); Assert.True(hotKeys.AllCommandsAllSlotsNetworkBytes > 0, nameof(hotKeys.AllCommandsAllSlotsNetworkBytes)); Assert.True(hotKeys.TotalNetworkBytes > 0, nameof(hotKeys.TotalNetworkBytes)); + + Assert.False(hotKeys.AllCommandsSelectedSlotsTime.HasValue); + Assert.False(hotKeys.AllCommandsSelectedSlotsNetworkBytes.HasValue); + Assert.False(hotKeys.SampledCommandsSelectedSlotsTime.HasValue); + Assert.False(hotKeys.SampledCommandsSelectedSlotsNetworkBytes.HasValue); } [Fact] From 2a3cf75ff3c3ee0629d96ef9bcb4509b59a87421 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Feb 2026 11:14:22 +0000 Subject: [PATCH 25/27] CI image update --- tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 9af6ee8cc..1a4602895 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -FROM redislabs/client-libs-test:custom-21651605017-debian-amd64 +FROM redislabs/client-libs-test:custom-21860421418-debian-amd64 COPY --from=configs ./Basic /data/Basic/ COPY --from=configs ./Failover /data/Failover/ From a4ab5568b5915ca8010fc610f2d996d2329259c7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Feb 2026 11:10:43 +0000 Subject: [PATCH 26/27] stabilize CI for Windows Server --- src/StackExchange.Redis/RedisFeatures.cs | 4 +- .../StackExchange.Redis.Tests/HotKeysTests.cs | 53 ++++++++++++++----- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index d0ea77707..d185089e6 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -48,8 +48,8 @@ namespace StackExchange.Redis v7_4_0 = new Version(7, 4, 0), v8_0_0_M04 = new Version(7, 9, 227), // 8.0 M04 is version 7.9.227 v8_2_0_rc1 = new Version(8, 1, 240), // 8.2 RC1 is version 8.1.240 - v8_4_0_rc1 = new Version(8, 3, 224), - v8_6_0 = new Version(8, 5, 999); // 8.4 RC1 is version 8.3.224 + v8_4_0_rc1 = new Version(8, 3, 224), // 8.4 RC1 is version 8.3.224 + v8_6_0 = new Version(8, 6, 0); #pragma warning restore SA1310 // Field names should not contain underscore #pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index ec22c4f0b..f6e4f189d 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -36,11 +36,21 @@ public void CanUseClusterFilter(bool sample) Assert.Equal(slot, slots[0].From); Assert.Equal(slot, slots[0].To); - Assert.Equal(1, result.CpuByKey.Length); - Assert.Equal(key, result.CpuByKey[0].Key); + Assert.False(result.CpuByKey.IsEmpty, "Expected at least one CPU result"); + bool found = false; + foreach (var cpu in result.CpuByKey) + { + if (cpu.Key == key) found = true; + } + Assert.True(found, "key not found in CPU results"); - Assert.Equal(1, result.NetworkBytesByKey.Length); - Assert.Equal(key, result.NetworkBytesByKey[0].Key); + Assert.False(result.NetworkBytesByKey.IsEmpty, "Expected at least one network result"); + found = false; + foreach (var net in result.NetworkBytesByKey) + { + if (net.Key == key) found = true; + } + Assert.True(found, "key not found in network results"); Assert.True(result.AllCommandSelectedSlotsMicroseconds >= 0, nameof(result.AllCommandSelectedSlotsMicroseconds)); Assert.True(result.TotalCpuTimeUserMicroseconds >= 0, nameof(result.TotalCpuTimeUserMicroseconds)); @@ -149,15 +159,23 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser Assert.True(hotKeys.CollectionDurationMicroseconds >= 0, nameof(hotKeys.CollectionDurationMicroseconds)); Assert.True(hotKeys.CollectionStartTimeUnixMilliseconds >= 0, nameof(hotKeys.CollectionStartTimeUnixMilliseconds)); - Assert.Equal(1, hotKeys.CpuByKey.Length); - var cpu = hotKeys.CpuByKey[0]; - Assert.Equal(key, cpu.Key); - Assert.True(cpu.DurationMicroseconds >= 0, nameof(cpu.DurationMicroseconds)); + Assert.False(hotKeys.CpuByKey.IsEmpty, "Expected at least one CPU result"); + bool found = false; + foreach (var cpu in hotKeys.CpuByKey) + { + Assert.True(cpu.DurationMicroseconds >= 0, nameof(cpu.DurationMicroseconds)); + if (cpu.Key == key) found = true; + } + Assert.True(found, "key not found in CPU results"); - Assert.Equal(1, hotKeys.NetworkBytesByKey.Length); - var net = hotKeys.NetworkBytesByKey[0]; - Assert.Equal(key, net.Key); - Assert.True(net.Bytes > 0, nameof(net.Bytes)); + Assert.False(hotKeys.NetworkBytesByKey.IsEmpty, "Expected at least one network result"); + found = false; + foreach (var net in hotKeys.NetworkBytesByKey) + { + Assert.True(net.Bytes > 0, nameof(net.Bytes)); + if (net.Key == key) found = true; + } + Assert.True(found, "key not found in network results"); Assert.Equal(1, hotKeys.SampleRatio); Assert.False(hotKeys.IsSampled, nameof(hotKeys.IsSampled)); @@ -269,6 +287,17 @@ public async Task MetricsChoiceAsync(HotKeysMetrics metrics) var result = await server.HotKeysGetAsync(); Assert.NotNull(result); Assert.Equal(metrics, result.Metrics); + + bool cpu = (metrics & HotKeysMetrics.Cpu) != 0; + bool net = (metrics & HotKeysMetrics.Network) != 0; + + Assert.NotEqual(cpu, result.CpuByKey.IsEmpty); + Assert.Equal(cpu, result.TotalCpuTimeSystem.HasValue); + Assert.Equal(cpu, result.TotalCpuTimeUser.HasValue); + Assert.Equal(cpu, result.TotalCpuTime.HasValue); + + Assert.NotEqual(net, result.NetworkBytesByKey.IsEmpty); + Assert.Equal(net, result.TotalNetworkBytes.HasValue); } [Fact] From ca694b8d89a6e3ee8c0fc7219a1b5501f7c56a3e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Feb 2026 11:12:39 +0000 Subject: [PATCH 27/27] be explicit about per-protocol/collection on cluster --- tests/StackExchange.Redis.Tests/HotKeysTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index f6e4f189d..5e2daa6b3 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -4,6 +4,8 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] +[Collection(NonParallelCollection.Name)] public class HotKeysClusterTests(ITestOutputHelper output, SharedConnectionFixture fixture) : HotKeysTests(output, fixture) { protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000";