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:
| ------------ | ----------------- | ----- |
| [](https://www.nuget.org/packages/StackExchange.Redis/) | [](https://www.nuget.org/packages/StackExchange.Redis/) | [](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);
+ }
+}