diff --git a/docs/HotKeys.md b/docs/HotKeys.md new file mode 100644 index 000000000..5ac7c86f9 --- /dev/null +++ b/docs/HotKeys.md @@ -0,0 +1,71 @@ +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. +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. +// 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)); + +// 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 +// 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. (also: `IsSampled`) +- `SelectedSlots`: The key slots active for this profiling session. +- `CollectionStartTime`: The start time of the capture. +- `CollectionDuration`: The duration of the capture. +- `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: + +- `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: + +- `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: + +- `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: + +- `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. + +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. diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index af7ecad45..c038ab327 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -6,12 +6,12 @@ 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 - +## 2.11.unreleased +- Add support for `HOTKEYS` ([#3008 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3008)) - Add support for keyspace notifications ([#2995 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2995)) +- Add support for idempotent stream entry (`XADD IDMP[AUTO]`) support ([#3006 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3006)) - (internals) split AMR out to a separate options provider ([#2986 by NickCraver and philon-msft](https://github.com/StackExchange/StackExchange.Redis/pull/2986)) -- Implement idempotent stream entry (IDMP) support ([#3006 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3006)) ## 2.10.14 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/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index 99488ddff..60e606ce2 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -45,6 +45,11 @@ private SlotRange(short from, short to) /// public int To => to; + internal const int MinSlot = 0, MaxSlot = 16383; + + 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/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 f731a6676..c55a39d8a 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, @@ -432,6 +433,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.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs new file mode 100644 index 000000000..a0f5b2892 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -0,0 +1,217 @@ +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; + } + + // an array with a single element that *is* an array/map that is the results + if (result is { Resp2TypeArray: ResultType.Array, ItemsCount: 1 }) + { + 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; + } + } + + 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()) 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 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) + { + _selectedSlots = []; + continue; + } + + 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) + { + 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 + { + 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 i64): + AllCommandsAllSlotsMicroseconds = i64; + break; + 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 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 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 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 i64): + NetworkBytesSampledCommandsSelectedSlotsRaw = i64; + break; + 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 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 i64): + CollectionDurationMicroseconds = i64; + break; + 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 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 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 i64): + metrics |= HotKeysMetrics.Cpu; + TotalCpuTimeUserMicroseconds = i64; + break; + case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out i64): + metrics |= HotKeysMetrics.Network; + 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; + len = value.ItemsCount / 2; + if (len == 0) + { + _cpuByKey = []; + continue; + } + + 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)) + { + 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: + metrics |= HotKeysMetrics.Network; + len = value.ItemsCount / 2; + if (len == 0) + { + _networkBytesByKey = []; + continue; + } + + 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)) + { + netBytes[i] = new(metricKey, metricValue); + } + } + + _networkBytesByKey = netBytes; + break; + } // switch + } // while + Metrics = metrics; + } + +#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 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 { } + [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 { } + + // 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..967a454e8 --- /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 = 0, + TimeSpan duration = default, + long sampleRatio = 1, + int[]? 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 = 0, + TimeSpan duration = default, + long sampleRatio = 1, + int[]? slots = null, + CommandFlags flags = CommandFlags.None) + => ExecuteAsync( + new HotKeysStartMessage(flags, metrics, count, duration, sampleRatio, slots), + ResultProcessor.DemandOK); + + 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.Boolean, 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..c9f0bc371 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -0,0 +1,80 @@ +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, + int[]? slots) : Message(-1, flags, RedisCommand.HOTKEYS) + { + protected override void WriteImpl(PhysicalConnection physical) + { + /* + HOTKEYS START + + [COUNT k] + [DURATION duration] + [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++; + 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 != 1) + { + 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 = 3; + 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 != 1) 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 new file mode 100644 index 000000000..270bcf9f7 --- /dev/null +++ b/src/StackExchange.Redis/HotKeys.cs @@ -0,0 +1,336 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public partial interface IServer +{ + /// + /// 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. 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"). + /// 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, + TimeSpan duration = default, + long sampleRatio = 1, + int[]? slots = null, + CommandFlags flags = CommandFlags.None); + + /// + /// 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. 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"). + /// 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, + TimeSpan duration = default, + long sampleRatio = 1, + int[]? slots = null, + CommandFlags flags = CommandFlags.None); + + /// + /// 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); + + /// + /// Fetch the most recent HOTKEYS profiling data. + /// + /// 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); + + /// + /// Fetch the most recent HOTKEYS profiling data. + /// + /// 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); +} + +/// +/// Metrics to record during HOTKEYS profiling. +/// +[Flags] +[Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] +public enum HotKeysMetrics +{ + /// + /// No metrics. + /// + None = 0, + + /// + /// Capture CPU time. + /// + Cpu = 1 << 0, + + /// + /// Capture network bytes. + /// + Network = 1 << 1, +} + +/// +/// Captured data from HOTKEYS profiling. +/// +[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. + /// + public HotKeysMetrics Metrics { get; } + + /// + /// Indicates whether the capture currently active. + /// + public bool TrackingActive { get; } + + /// + /// Profiling frequency; effectively: measure every Nth command. + /// + public long SampleRatio { get; } + + /// + /// Gets whether sampling is in use. + /// + public bool IsSampled => SampleRatio > 1; + + /// + /// The key slots active for this profiling session. + /// + public ReadOnlySpan SelectedSlots => _selectedSlots; + + private readonly SlotRange[]? _selectedSlots; + + /// + /// Gets whether slot filtering is in use. + /// + 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 AllCommandsAllSlotsTime => NonNegativeMicroseconds(AllCommandsAllSlotsMicroseconds); + + internal long AllCommandsAllSlotsMicroseconds { 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. + /// + 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. + /// + public TimeSpan? SampledCommandsSelectedSlotsTime => SampledCommandsSelectedSlotsMicroseconds < 0 + ? null + : NonNegativeMicroseconds(SampledCommandsSelectedSlotsMicroseconds); + + 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 network usage measured for all commands in all slots, without any sampling or filtering applied. + /// + 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. + /// + 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. + /// + 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)); + + internal long CollectionDurationMicroseconds { get; } + + /// + /// The duration of the capture. + /// + public TimeSpan CollectionDuration => NonNegativeMicroseconds(CollectionDurationMicroseconds); + + internal long TotalCpuTimeUserMicroseconds { get; } = -1; + + /// + /// The total user CPU time measured in the profiling session. + /// + public TimeSpan? TotalCpuTimeUser => TotalCpuTimeUserMicroseconds < 0 + ? null + : NonNegativeMicroseconds(TotalCpuTimeUserMicroseconds); + + internal long TotalCpuTimeSystemMicroseconds { get; } = -1; + + /// + /// The total system CPU measured in the profiling session. + /// + public TimeSpan? TotalCpuTimeSystem => TotalCpuTimeSystemMicroseconds < 0 + ? null + : NonNegativeMicroseconds(TotalCpuTimeSystemMicroseconds); + + /// + /// The total CPU time measured in the profiling session (this is just + ). + /// + public TimeSpan? TotalCpuTime => TotalCpuTimeUser + TotalCpuTimeSystem; + + internal long TotalNetworkBytesRaw { get; } = -1; + + /// + /// The total network data measured in the profiling session. + /// + 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, + // we'll preserve the server's display order. + + /// + /// Hot keys, as measured by CPU activity. + /// + public ReadOnlySpan CpuByKey => _cpuByKey; + + private readonly MetricKeyCpu[]? _cpuByKey; + + /// + /// Hot keys, as measured by network activity. + /// + public ReadOnlySpan NetworkBytesByKey => _networkBytesByKey; + + 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 durationMicroseconds) + { + private readonly RedisKey _key = key; + + /// + /// The key observed. + /// + public RedisKey Key => _key; + + internal long DurationMicroseconds => durationMicroseconds; + + /// + /// The time taken. + /// + public TimeSpan Duration => NonNegativeMicroseconds(durationMicroseconds); + + /// + public override string ToString() => $"{_key}: {Duration}"; + + /// + public override int GetHashCode() => _key.GetHashCode() ^ durationMicroseconds.GetHashCode(); + + /// + public override bool Equals(object? obj) + => obj is MetricKeyCpu other && _key.Equals(other.Key) && + durationMicroseconds == other.DurationMicroseconds; + } + + /// + /// 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; + + /// + 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/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 3a80ab570..1b2aa2a9e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,53 @@ #nullable enable +[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.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 +[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 +[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 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.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! +[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, 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 +[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! [SER003]override StackExchange.Redis.StreamIdempotentId.Equals(object? obj) -> bool [SER003]override StackExchange.Redis.StreamIdempotentId.GetHashCode() -> int [SER003]override StackExchange.Redis.StreamIdempotentId.ToString() -> string! 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/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index dd1522d71..be79b3267 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -166,6 +166,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; 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 @@ + + diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index e32d53161..363edde51 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,5 @@ FROM redislabs/client-libs-test:8.6.0 + COPY --from=configs ./Basic /data/Basic/ COPY --from=configs ./Failover /data/Failover/ COPY --from=configs ./Cluster /data/Cluster/ diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs new file mode 100644 index 000000000..5e2daa6b3 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +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"; + + [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], sampleRatio: sample ? 3 : 1, duration: Duration); + + 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); + Assert.True(result.IsSlotFiltered, nameof(result.IsSlotFiltered)); + var slots = result.SelectedSlots; + Assert.Equal(1, slots.Length); + Assert.Equal(slot, slots[0].From); + Assert.Equal(slot, slots[0].To); + + 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.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)); + + Assert.Equal(sample, result.IsSampled); + if (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); + } +} + +[RunPerProtocol] +[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); + + private protected IConnectionMultiplexer GetServer(in RedisKey key, out IServer server) + { + 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); + 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(duration: Duration); + 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, server); + + Assert.True(server.HotKeysStop()); + result = server.HotKeysGet(); + Assert.NotNull(result); + Assert.False(result.TrackingActive); + CheckSimpleWithKey(key, result, server); + + server.HotKeysReset(); + result = server.HotKeysGet(); + Assert.Null(result); + } + + private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer server) + { + 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.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.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)); + Assert.False(hotKeys.IsSlotFiltered, nameof(hotKeys.IsSlotFiltered)); + + 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.AllCommandsAllSlotsMicroseconds >= 0, nameof(hotKeys.AllCommandsAllSlotsMicroseconds)); + Assert.True(hotKeys.TotalCpuTimeSystemMicroseconds >= 0, nameof(hotKeys.TotalCpuTimeSystemMicroseconds)); + 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] + public async Task CanStartStopResetAsync() + { + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + await server.HotKeysStartAsync(duration: Duration); + 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, server); + + Assert.True(await server.HotKeysStopAsync()); + result = await server.HotKeysGetAsync(); + Assert.NotNull(result); + Assert.False(result.TrackingActive); + CheckSimpleWithKey(key, result, server); + + await server.HotKeysResetAsync(); + 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); + + 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, duration: Duration); + 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); + + 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] + public async Task SampleRatioUsageAsync() + { + RedisKey key = Me(); + await using var muxer = GetServer(key, out var server); + 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++) + { + 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.True(result.TotalNetworkBytes.HasValue); + Assert.True(result.TotalCpuTime.HasValue); + } +}