Skip to main content

redis/commands/
hotkeys.rs

1//! Defines types to use with the HOTKEYS commands.
2//!
3//! The HOTKEYS command is a stateful, node-local command requiring session affinity.
4//! It should only be used on standalone clients (Connection, MultiplexedConnection, etc.)
5//! and NOT on cluster clients (ClusterConnection).
6//!
7//! # Command Syntax (Redis 8.6.0+)
8//!
9//! ```text
10//! HOTKEYS START METRICS count [CPU] [NET] [COUNT k] [DURATION seconds] [SAMPLE ratio] [SLOTS count slot [slot ...]]
11//! HOTKEYS GET
12//! HOTKEYS STOP
13//! HOTKEYS RESET
14//! ```
15//!
16//! # Using HOTKEYS in Cluster Mode
17//!
18//! While the high-level `HotkeysCommands` trait is not available on `ClusterConnection`,
19//! HOTKEYS commands can still be used in cluster mode through `route_command` with
20//! explicit routing. Two patterns are useful:
21//!
22//! ## Routing to a single node
23//!
24//! Useful for inspecting a specific shard, e.g. when investigating a known hot slot.
25//!
26//! ```rust,no_run
27//! # #[cfg(feature = "cluster")]
28//! # {
29//! use redis::cluster::ClusterClient;
30//! use redis::cluster_routing::{RoutingInfo, SingleNodeRoutingInfo};
31//! use redis::{cmd, FromRedisValue, HotkeysOptions, HotkeysResponse};
32//!
33//! let nodes = vec!["redis://127.0.0.1:6379/", "redis://127.0.0.1:6378/"];
34//! let client = ClusterClient::new(nodes).unwrap();
35//! let mut connection = client.get_connection().unwrap();
36//!
37//! // Route to a specific node
38//! let routing = RoutingInfo::SingleNode(SingleNodeRoutingInfo::ByAddress {
39//!     host: "127.0.0.1".to_string(),
40//!     port: 6379,
41//! });
42//!
43//! // Start tracking on that specific node - track keys by CPU time percentage
44//! let opts = HotkeysOptions::new_with_cpu();
45//! let _ = connection.route_command(
46//!     &cmd("HOTKEYS").arg("START").arg(opts),
47//!     routing.clone()
48//! ).unwrap();
49//!
50//! // ... perform operations ...
51//!
52//! // Get metrics from the same node
53//! let value = connection.route_command(
54//!     &cmd("HOTKEYS").arg("GET"),
55//!     routing.clone()
56//! ).unwrap();
57//! let response = HotkeysResponse::from_redis_value(value).unwrap();
58//!
59//! // Stop tracking on that node
60//! let _ = connection.route_command(
61//!     &cmd("HOTKEYS").arg("STOP"),
62//!     routing
63//! ).unwrap();
64//! # }
65//! ```
66//!
67//! ## Routing to all primaries (cluster-wide tracking)
68//!
69//! For cluster-wide observability the natural pattern is to fan the command out to
70//! every primary so each shard tracks its own keys. `START` and `STOP` can use
71//! [`ResponsePolicy::AllSucceeded`](crate::cluster_routing::ResponsePolicy::AllSucceeded)
72//! to assert every node accepted the command. `GET` should be issued without a
73//! response policy: the response is then a [`Value::Map`] keyed by node address,
74//! and each entry is parsed with [`HotkeysResponse::from_redis_value`].
75//!
76//! ```rust,no_run
77//! # #[cfg(feature = "cluster")]
78//! # {
79//! use redis::cluster::ClusterClient;
80//! use redis::cluster_routing::{
81//!     MultipleNodeRoutingInfo, ResponsePolicy, RoutingInfo,
82//! };
83//! use redis::{cmd, from_redis_value, FromRedisValue, HotkeysOptions, HotkeysResponse, Value};
84//!
85//! let nodes = vec!["redis://127.0.0.1:6379/", "redis://127.0.0.1:6378/"];
86//! let client = ClusterClient::new(nodes).unwrap();
87//! let mut connection = client.get_connection().unwrap();
88//!
89//! // START on every primary and require success everywhere.
90//! // `with_slots` is not used in here and each primary tracks the keys for the slots it owns.
91//! let start_opts = HotkeysOptions::new_with_cpu().and_net();
92//! let _ = connection.route_command(
93//!     &cmd("HOTKEYS").arg("START").arg(&start_opts),
94//!     RoutingInfo::MultiNode((
95//!         MultipleNodeRoutingInfo::AllMasters,
96//!         Some(ResponsePolicy::AllSucceeded),
97//!     )),
98//! ).unwrap();
99//!
100//! // ... perform operations ...
101//!
102//! // GET from every primary with no policy.
103//! // The cluster client will get back the per-node responses.
104//! let value = connection.route_command(
105//!     &cmd("HOTKEYS").arg("GET"),
106//!     RoutingInfo::MultiNode((MultipleNodeRoutingInfo::AllMasters, None)),
107//! ).unwrap();
108//! if let Value::Map(per_node) = value {
109//!     for (addr_val, response_val) in per_node {
110//!         let addr: String = from_redis_value(addr_val).unwrap();
111//!         let snapshot = HotkeysResponse::from_redis_value(response_val).unwrap();
112//!         println!("node {addr}: {} hot keys by CPU",
113//!             snapshot.by_cpu_time_us.as_ref().map(|v| v.len()).unwrap_or(0));
114//!     }
115//! }
116//!
117//! // STOP on every primary.
118//! let _ = connection.route_command(
119//!     &cmd("HOTKEYS").arg("STOP"),
120//!     RoutingInfo::MultiNode((
121//!         MultipleNodeRoutingInfo::AllMasters,
122//!         Some(ResponsePolicy::AllSucceeded),
123//!     )),
124//! ).unwrap();
125//! # }
126//! ```
127
128use crate::errors::ParsingError;
129use crate::types::{FromRedisValue, RedisWrite, ToRedisArgs, Value};
130use std::collections::HashMap;
131
132/// Minimum value for the COUNT parameter of HOTKEYS START.
133pub const HOTKEYS_COUNT_MIN: u64 = 1;
134/// Maximum value for the COUNT parameter of HOTKEYS START.
135pub const HOTKEYS_COUNT_MAX: u64 = 64;
136
137/// Options for the HOTKEYS START command.
138///
139/// At least one of `cpu` or `net` must be enabled to specify which metrics to collect.
140/// The `METRICS count` is automatically derived from how many metric types are enabled.
141///
142/// Use [`HotkeysOptions::new_with_cpu()`] or [`HotkeysOptions::new_with_net()`] constructors to create
143/// valid options with at least one metric enabled.
144///
145/// # Example
146///
147/// ```rust,no_run
148/// use redis::{HotkeysOptions, HotkeysCommands};
149///
150/// # fn example() -> redis::RedisResult<()> {
151/// let client = redis::Client::open("redis://127.0.0.1/")?;
152/// let mut con = client.get_connection()?;
153///
154/// // Track hotkeys by both CPU and network usage for 60 seconds
155/// let opts = HotkeysOptions::new_with_cpu()
156///     .and_net()
157///     .with_duration_secs(60);
158///
159/// con.hotkeys_start(opts)?;
160/// # Ok(())
161/// # }
162/// ```
163#[derive(Clone, Debug)]
164#[non_exhaustive]
165pub struct HotkeysOptions {
166    /// Track hotkeys by CPU time percentage
167    cpu: bool,
168    /// Track hotkeys by network bytes percentage
169    net: bool,
170    /// Value of K for top-K hotkeys tracking (optional COUNT parameter)
171    count_k: Option<u64>,
172    /// Duration in seconds for tracking (optional DURATION parameter)
173    duration_secs: Option<u64>,
174    /// Sampling ratio for probabilistic tracking (optional SAMPLE parameter)
175    sample_ratio: Option<u64>,
176    /// Specific slots to track in cluster mode (optional SLOTS parameter)
177    slots: Option<Vec<u16>>,
178}
179
180impl HotkeysOptions {
181    /// Creates options to track hotkeys by CPU time percentage.
182    ///
183    /// # Example
184    ///
185    /// ```rust
186    /// use redis::HotkeysOptions;
187    ///
188    /// // Track hotkeys by CPU time
189    /// let opts = HotkeysOptions::new_with_cpu();
190    ///
191    /// // Track by both CPU and network
192    /// let opts = HotkeysOptions::new_with_cpu().and_net();
193    /// ```
194    pub fn new_with_cpu() -> Self {
195        Self {
196            cpu: true,
197            net: false,
198            count_k: None,
199            duration_secs: None,
200            sample_ratio: None,
201            slots: None,
202        }
203    }
204
205    /// Creates options to track hotkeys by network bytes percentage.
206    ///
207    /// # Example
208    ///
209    /// ```rust
210    /// use redis::HotkeysOptions;
211    ///
212    /// // Track hotkeys by network bytes
213    /// let opts = HotkeysOptions::new_with_net();
214    ///
215    /// // Track by both network and CPU
216    /// let opts = HotkeysOptions::new_with_net().and_cpu();
217    /// ```
218    pub fn new_with_net() -> Self {
219        Self {
220            cpu: false,
221            net: true,
222            count_k: None,
223            duration_secs: None,
224            sample_ratio: None,
225            slots: None,
226        }
227    }
228
229    /// Also track hotkeys by CPU time percentage.
230    ///
231    /// Used when both metrics are needed and the options were created using [`HotkeysOptions::new_with_net()`]
232    pub fn and_cpu(mut self) -> Self {
233        self.cpu = true;
234        self
235    }
236
237    /// Also track hotkeys by network bytes percentage.
238    ///
239    /// Used when both metrics are needed and the options were created using [`HotkeysOptions::new_with_cpu()`]
240    pub fn and_net(mut self) -> Self {
241        self.net = true;
242        self
243    }
244
245    /// Returns the number of metrics being tracked.
246    fn metrics_count(&self) -> u64 {
247        self.cpu as u64 + self.net as u64
248    }
249
250    /// Set the value of K for top-K hotkeys tracking.
251    ///
252    /// This is the COUNT parameter in the Redis command.
253    ///
254    /// # Errors
255    /// Returns an error if `k` is not in the valid range `1..=64`
256    /// (see [`HOTKEYS_COUNT_MIN`] and [`HOTKEYS_COUNT_MAX`]).
257    pub fn with_count(mut self, k: u64) -> Result<Self, String> {
258        if !(HOTKEYS_COUNT_MIN..=HOTKEYS_COUNT_MAX).contains(&k) {
259            return Err(format!(
260                "COUNT must be between {HOTKEYS_COUNT_MIN} and {HOTKEYS_COUNT_MAX}, got: {k}"
261            ));
262        }
263        self.count_k = Some(k);
264        Ok(self)
265    }
266
267    /// Set the duration in seconds for how long tracking should run.
268    ///
269    /// After this time period, tracking will automatically stop.
270    /// If not specified, tracking continues until manually stopped with HOTKEYS STOP.
271    pub fn with_duration_secs(mut self, seconds: u64) -> Self {
272        self.duration_secs = Some(seconds);
273        self
274    }
275
276    /// Set the sampling ratio for probabilistic tracking.
277    ///
278    /// Each key is sampled with probability 1/ratio. Higher values reduce
279    /// performance impact but may miss some hotkeys. Lower values provide
280    /// more accurate results but with higher performance cost.
281    pub fn with_sample_ratio(mut self, ratio: u64) -> Self {
282        self.sample_ratio = Some(ratio);
283        self
284    }
285
286    /// Set specific hash slots to track in a cluster environment.
287    ///
288    /// Only keys that hash to the specified slots will be tracked.
289    /// Useful for narrowing tracking to a subset of the slots owned by a single shard.
290    ///
291    /// # When to use
292    ///
293    /// This option is meaningful only in cluster mode and only with **single-node**
294    /// routing (e.g. [`RoutingInfo::SingleNode`](crate::cluster_routing::RoutingInfo::SingleNode))
295    /// targeting a primary that owns the requested slots. The Redis server will
296    /// reject the command if any of the requested slots are not owned by the
297    /// receiving node.
298    ///
299    /// When fanning the command out to every node (e.g.
300    /// [`MultipleNodeRoutingInfo::AllMasters`](crate::cluster_routing::MultipleNodeRoutingInfo::AllMasters)),
301    /// omit `with_slots` and let each primary track the keys for the slots it
302    /// owns, otherwise every primary that does not own the supplied slot list
303    /// will return an error.
304    ///
305    /// Note: Using SLOTS when not in cluster mode will result in an error.
306    pub fn with_slots(mut self, slots: Vec<u16>) -> Self {
307        self.slots = Some(slots);
308        self
309    }
310}
311
312impl ToRedisArgs for HotkeysOptions {
313    fn write_redis_args<W>(&self, out: &mut W)
314    where
315        W: ?Sized + RedisWrite,
316    {
317        // METRICS count [CPU] [NET] - required
318        out.write_arg(b"METRICS");
319        out.write_arg_fmt(self.metrics_count());
320
321        if self.cpu {
322            out.write_arg(b"CPU");
323        }
324
325        if self.net {
326            out.write_arg(b"NET");
327        }
328
329        // Optional: COUNT k
330        if let Some(k) = self.count_k {
331            out.write_arg(b"COUNT");
332            out.write_arg_fmt(k);
333        }
334
335        // Optional: DURATION seconds
336        if let Some(secs) = self.duration_secs {
337            out.write_arg(b"DURATION");
338            out.write_arg_fmt(secs);
339        }
340
341        // Optional: SAMPLE ratio
342        if let Some(ratio) = self.sample_ratio {
343            out.write_arg(b"SAMPLE");
344            out.write_arg_fmt(ratio);
345        }
346
347        // Optional: SLOTS count slot [slot ...]
348        if let Some(ref slots) = self.slots {
349            out.write_arg(b"SLOTS");
350            out.write_arg_fmt(slots.len());
351            for slot in slots {
352                out.write_arg_fmt(slot);
353            }
354        }
355    }
356
357    fn num_of_args(&self) -> usize {
358        // METRICS + count
359        let mut n = 2;
360        n += self.cpu as usize;
361        n += self.net as usize;
362        if self.count_k.is_some() {
363            n += 2;
364        }
365        if self.duration_secs.is_some() {
366            n += 2;
367        }
368        if self.sample_ratio.is_some() {
369            n += 2;
370        }
371        if let Some(ref slots) = self.slots {
372            // SLOTS + count + one arg per slot
373            n += 2 + slots.len();
374        }
375        n
376    }
377}
378
379/// A single hotkey entry with its metric value.
380#[derive(Debug, Clone, PartialEq)]
381#[non_exhaustive]
382pub struct HotKeyEntry {
383    /// The key name.
384    pub key: String,
385    /// The metric value (CPU time in microseconds or network bytes, depending on context).
386    pub value: u64,
387}
388
389/// Represents a range of slots.
390#[derive(Debug, Clone, PartialEq)]
391#[non_exhaustive]
392pub struct SlotRange {
393    /// Start of the slot range (inclusive).
394    pub start: u16,
395    /// End of the slot range (inclusive).
396    pub end: u16,
397}
398
399/// Response from the HOTKEYS GET command.
400///
401/// Contains information about the hotkeys tracking session,
402/// including tracking metadata, performance statistics, and lists of top K
403/// hot keys sorted by the metrics specified in HOTKEYS START.
404#[derive(Debug, Clone, PartialEq, Default)]
405#[non_exhaustive]
406pub struct HotkeysResponse {
407    /// Whether tracking is currently active (1) or stopped (0).
408    pub tracking_active: bool,
409    /// The sampling ratio used during tracking.
410    pub sample_ratio: u64,
411    /// Array of selected slot ranges.
412    pub selected_slots: Vec<SlotRange>,
413    /// CPU time in microseconds for all commands on all slots.
414    pub all_commands_all_slots_us: u64,
415    /// Network bytes for all commands on all slots.
416    pub net_bytes_all_commands_all_slots: u64,
417    /// Unix timestamp in milliseconds when tracking started.
418    pub collection_start_time_unix_ms: u64,
419    /// Duration of tracking in milliseconds.
420    pub collection_duration_ms: u64,
421    /// User CPU time used in milliseconds (only when CPU metric was specified).
422    pub total_cpu_time_user_ms: Option<u64>,
423    /// System CPU time used in milliseconds (only when CPU metric was specified).
424    pub total_cpu_time_sys_ms: Option<u64>,
425    /// Total network bytes processed (only when NET metric was specified).
426    pub total_net_bytes: Option<u64>,
427    /// Array of hotkeys sorted by CPU time in microseconds (only when CPU metric was specified).
428    pub by_cpu_time_us: Option<Vec<HotKeyEntry>>,
429    /// Array of hotkeys sorted by network bytes (only when NET metric was specified).
430    pub by_net_bytes: Option<Vec<HotKeyEntry>>,
431
432    // Cluster-specific fields (when SLOTS was used)
433    /// CPU time in microseconds for sampled commands in selected slots (cluster mode with SAMPLE).
434    pub sampled_commands_selected_slots_us: Option<u64>,
435    /// CPU time in microseconds for all commands in selected slots (cluster mode).
436    pub all_commands_selected_slots_us: Option<u64>,
437    /// Network bytes for sampled commands in selected slots (cluster mode with SAMPLE).
438    pub net_bytes_sampled_commands_selected_slots: Option<u64>,
439    /// Network bytes for all commands on selected slots (cluster mode).
440    pub net_bytes_all_commands_selected_slots: Option<u64>,
441}
442
443/// Helper to strip surrounding quotes from a string if present
444fn strip_quotes(s: String) -> String {
445    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
446        s[1..s.len() - 1].to_string()
447    } else {
448        s
449    }
450}
451
452/// Helper function to parse a key-value pair array into HotKeyEntry vec
453fn parse_hotkey_entries(arr: &[Value]) -> Result<Vec<HotKeyEntry>, ParsingError> {
454    use crate::types::from_redis_value_ref;
455
456    let mut entries = Vec::with_capacity(arr.len() / 2);
457
458    let mut iter = arr.iter();
459    while let Some(key_val) = iter.next() {
460        let key: String = from_redis_value_ref(key_val)?;
461        // Strip surrounding quotes if present (Redis returns quoted keys)
462        let key = strip_quotes(key);
463        let value: u64 = iter
464            .next()
465            .ok_or_else(|| ParsingError::from("Expected value after key in hotkey entry"))
466            .and_then(from_redis_value_ref)?;
467
468        entries.push(HotKeyEntry { key, value });
469    }
470
471    Ok(entries)
472}
473
474/// Helper function to parse slot ranges from the selected-slots array
475fn parse_slot_ranges(arr: &[Value]) -> Result<Vec<SlotRange>, ParsingError> {
476    use crate::types::from_redis_value_ref;
477
478    let mut ranges = Vec::with_capacity(arr.len());
479
480    for item in arr {
481        let Value::Array(range_arr) = item else {
482            crate::errors::invalid_type_error!("Expected array for slot range", item);
483        };
484
485        match range_arr.len() {
486            1 => {
487                let slot: u16 = from_redis_value_ref(&range_arr[0])?;
488                ranges.push(SlotRange {
489                    start: slot,
490                    end: slot,
491                });
492            }
493            n if n >= 2 => {
494                let start: u16 = from_redis_value_ref(&range_arr[0])?;
495                let end: u16 = from_redis_value_ref(&range_arr[1])?;
496                ranges.push(SlotRange { start, end });
497            }
498            _ => crate::errors::invalid_type_error!("Empty slot range entry", range_arr),
499        }
500    }
501
502    Ok(ranges)
503}
504
505impl FromRedisValue for HotkeysResponse {
506    fn from_redis_value(v: Value) -> Result<Self, ParsingError> {
507        use crate::types::from_redis_value;
508
509        // Redis 8.6 wraps every HOTKEYS GET response in a single-element outer
510        // array with one entry per tracking session. Unwrap it here so the inner
511        // value (Array of field/value pairs in RESP2, Map in RESP3) can be
512        // parsed uniformly below. The passthrough arm keeps unit-test fixtures
513        // that build the inner value directly working without needing to
514        // mirror the server's outer wrapping.
515        let v = match v {
516            Value::Array(mut arr) if arr.len() == 1 => arr.remove(0),
517            other => other,
518        };
519
520        // The response can be an Array (RESP2) or Map (RESP3)
521        // Parse it into a HashMap for easier field access
522        let mut fields: HashMap<String, Value> = match v {
523            Value::Array(arr) => {
524                // RESP2: flat array with alternating field names and values
525                let mut map = HashMap::new();
526                let mut iter = arr.into_iter();
527                while let Some(key) = iter.next() {
528                    let key_str: String = from_redis_value(key)?;
529                    // Strip surrounding quotes if present (Redis returns quoted keys)
530                    let key_str = strip_quotes(key_str);
531                    if let Some(val) = iter.next() {
532                        map.insert(key_str, val);
533                    }
534                }
535                map
536            }
537            Value::Map(pairs) => {
538                // RESP3: proper map
539                let mut map = HashMap::new();
540                for (k, v) in pairs {
541                    let key_str: String = from_redis_value(k)?;
542                    let key_str = strip_quotes(key_str);
543                    map.insert(key_str, v);
544                }
545                map
546            }
547            _ => {
548                crate::errors::invalid_type_error!(
549                    "Expected array or map response for HOTKEYS GET",
550                    v
551                );
552            }
553        };
554
555        let mut response = HotkeysResponse::default();
556
557        // Parse required fields
558        if let Some(v) = fields.remove("tracking-active") {
559            response.tracking_active = from_redis_value::<i64>(v)? != 0;
560        }
561
562        if let Some(v) = fields.remove("sample-ratio") {
563            response.sample_ratio = from_redis_value(v)?;
564        }
565
566        if let Some(Value::Array(arr)) = fields.remove("selected-slots") {
567            response.selected_slots = parse_slot_ranges(&arr)?;
568        }
569
570        if let Some(v) = fields.remove("all-commands-all-slots-us") {
571            response.all_commands_all_slots_us = from_redis_value(v)?;
572        }
573
574        if let Some(v) = fields.remove("net-bytes-all-commands-all-slots") {
575            response.net_bytes_all_commands_all_slots = from_redis_value(v)?;
576        }
577
578        if let Some(v) = fields.remove("collection-start-time-unix-ms") {
579            response.collection_start_time_unix_ms = from_redis_value(v)?;
580        }
581
582        if let Some(v) = fields.remove("collection-duration-ms") {
583            response.collection_duration_ms = from_redis_value(v)?;
584        }
585
586        // Parse optional CPU-related fields
587        if let Some(v) = fields.remove("total-cpu-time-user-ms") {
588            response.total_cpu_time_user_ms = Some(from_redis_value(v)?);
589        }
590
591        if let Some(v) = fields.remove("total-cpu-time-sys-ms") {
592            response.total_cpu_time_sys_ms = Some(from_redis_value(v)?);
593        }
594
595        if let Some(Value::Array(arr)) = fields.remove("by-cpu-time-us") {
596            response.by_cpu_time_us = Some(parse_hotkey_entries(&arr)?);
597        }
598
599        // Parse optional NET-related fields
600        if let Some(v) = fields.remove("total-net-bytes") {
601            response.total_net_bytes = Some(from_redis_value(v)?);
602        }
603
604        if let Some(Value::Array(arr)) = fields.remove("by-net-bytes") {
605            response.by_net_bytes = Some(parse_hotkey_entries(&arr)?);
606        }
607
608        // Parse cluster-specific fields
609        if let Some(v) = fields.remove("sampled-commands-selected-slots-us") {
610            response.sampled_commands_selected_slots_us = Some(from_redis_value(v)?);
611        }
612
613        if let Some(v) = fields.remove("all-commands-selected-slots-us") {
614            response.all_commands_selected_slots_us = Some(from_redis_value(v)?);
615        }
616
617        if let Some(v) = fields.remove("net-bytes-sampled-commands-selected-slots") {
618            response.net_bytes_sampled_commands_selected_slots = Some(from_redis_value(v)?);
619        }
620
621        if let Some(v) = fields.remove("net-bytes-all-commands-selected-slots") {
622            response.net_bytes_all_commands_selected_slots = Some(from_redis_value(v)?);
623        }
624
625        Ok(response)
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn test_hotkeys_options_cpu_constructor() {
635        let opts = HotkeysOptions::new_with_cpu();
636        assert_eq!(opts.num_of_args(), 3); // METRICS 1 CPU
637        let args = opts.to_redis_args();
638        assert_eq!(args.len(), 3);
639        assert_eq!(args[0], b"METRICS");
640        assert_eq!(args[1], b"1");
641        assert_eq!(args[2], b"CPU");
642    }
643
644    #[test]
645    fn test_hotkeys_options_net_constructor() {
646        let opts = HotkeysOptions::new_with_net();
647        assert_eq!(opts.num_of_args(), 3); // METRICS 1 NET
648        let args = opts.to_redis_args();
649        assert_eq!(args.len(), 3);
650        assert_eq!(args[0], b"METRICS");
651        assert_eq!(args[1], b"1");
652        assert_eq!(args[2], b"NET");
653    }
654
655    #[test]
656    fn test_hotkeys_options_cpu_and_net() {
657        let opts = HotkeysOptions::new_with_cpu().and_net();
658        assert_eq!(opts.num_of_args(), 4); // METRICS 2 CPU NET
659        let args = opts.to_redis_args();
660        assert_eq!(args.len(), 4);
661        assert_eq!(args[0], b"METRICS");
662        assert_eq!(args[1], b"2");
663        assert_eq!(args[2], b"CPU");
664        assert_eq!(args[3], b"NET");
665    }
666
667    #[test]
668    fn test_hotkeys_options_net_and_cpu() {
669        let opts = HotkeysOptions::new_with_net().and_cpu();
670        assert_eq!(opts.num_of_args(), 4); // METRICS 2 CPU NET
671        let args = opts.to_redis_args();
672        assert_eq!(args.len(), 4);
673        assert_eq!(args[0], b"METRICS");
674        assert_eq!(args[1], b"2");
675        // CPU comes before NET in serialization order
676        assert_eq!(args[2], b"CPU");
677        assert_eq!(args[3], b"NET");
678    }
679
680    #[test]
681    fn test_hotkeys_options_with_duration() {
682        let opts = HotkeysOptions::new_with_cpu().with_duration_secs(60);
683        assert_eq!(opts.num_of_args(), 5); // METRICS 1 CPU DURATION 60
684        let args = opts.to_redis_args();
685        assert_eq!(args.len(), 5);
686        assert_eq!(args[0], b"METRICS");
687        assert_eq!(args[1], b"1");
688        assert_eq!(args[2], b"CPU");
689        assert_eq!(args[3], b"DURATION");
690        assert_eq!(args[4], b"60");
691    }
692
693    #[test]
694    fn test_hotkeys_options_with_count() {
695        let opts = HotkeysOptions::new_with_cpu().with_count(50).unwrap();
696        assert_eq!(opts.num_of_args(), 5); // METRICS 1 CPU COUNT 50
697        let args = opts.to_redis_args();
698        assert_eq!(args.len(), 5);
699        assert_eq!(args[0], b"METRICS");
700        assert_eq!(args[1], b"1");
701        assert_eq!(args[2], b"CPU");
702        assert_eq!(args[3], b"COUNT");
703        assert_eq!(args[4], b"50");
704    }
705
706    #[test]
707    fn test_hotkeys_options_with_count_min_valid() {
708        let opts = HotkeysOptions::new_with_cpu()
709            .with_count(HOTKEYS_COUNT_MIN)
710            .unwrap();
711        let args = opts.to_redis_args();
712        assert_eq!(args[3], b"COUNT");
713        assert_eq!(args[4], HOTKEYS_COUNT_MIN.to_string().as_bytes());
714    }
715
716    #[test]
717    fn test_hotkeys_options_with_count_max_valid() {
718        let opts = HotkeysOptions::new_with_cpu()
719            .with_count(HOTKEYS_COUNT_MAX)
720            .unwrap();
721        let args = opts.to_redis_args();
722        assert_eq!(args[3], b"COUNT");
723        assert_eq!(args[4], HOTKEYS_COUNT_MAX.to_string().as_bytes());
724    }
725
726    #[test]
727    fn test_hotkeys_options_with_count_too_low() {
728        let result = HotkeysOptions::new_with_cpu().with_count(HOTKEYS_COUNT_MIN - 1);
729        assert!(result.is_err());
730        assert!(result.unwrap_err().contains(&format!(
731            "COUNT must be between {HOTKEYS_COUNT_MIN} and {HOTKEYS_COUNT_MAX}"
732        )));
733    }
734
735    #[test]
736    fn test_hotkeys_options_with_count_too_high() {
737        let result = HotkeysOptions::new_with_cpu().with_count(HOTKEYS_COUNT_MAX + 1);
738        assert!(result.is_err());
739        assert!(result.unwrap_err().contains(&format!(
740            "COUNT must be between {HOTKEYS_COUNT_MIN} and {HOTKEYS_COUNT_MAX}"
741        )));
742    }
743
744    #[test]
745    fn test_hotkeys_options_with_sample() {
746        let opts = HotkeysOptions::new_with_cpu().with_sample_ratio(1000);
747        assert_eq!(opts.num_of_args(), 5); // METRICS 1 CPU SAMPLE 1000
748        let args = opts.to_redis_args();
749        assert_eq!(args.len(), 5);
750        assert_eq!(args[0], b"METRICS");
751        assert_eq!(args[1], b"1");
752        assert_eq!(args[2], b"CPU");
753        assert_eq!(args[3], b"SAMPLE");
754        assert_eq!(args[4], b"1000");
755    }
756
757    #[test]
758    fn test_hotkeys_options_with_slots() {
759        let opts = HotkeysOptions::new_with_cpu().with_slots(vec![0, 100, 200]);
760        assert_eq!(opts.num_of_args(), 8); // METRICS 1 CPU SLOTS 3 0 100 200
761        let args = opts.to_redis_args();
762        assert_eq!(args.len(), 8);
763        assert_eq!(args[0], b"METRICS");
764        assert_eq!(args[1], b"1");
765        assert_eq!(args[2], b"CPU");
766        assert_eq!(args[3], b"SLOTS");
767        assert_eq!(args[4], b"3");
768        assert_eq!(args[5], b"0");
769        assert_eq!(args[6], b"100");
770        assert_eq!(args[7], b"200");
771    }
772
773    #[test]
774    fn test_hotkeys_options_full() {
775        let opts = HotkeysOptions::new_with_cpu()
776            .and_net()
777            .with_count(50)
778            .unwrap()
779            .with_duration_secs(120)
780            .with_sample_ratio(500);
781        // METRICS 2 CPU NET COUNT 50 DURATION 120 SAMPLE 500
782        assert_eq!(opts.num_of_args(), 10);
783        let args = opts.to_redis_args();
784        assert_eq!(args[0], b"METRICS");
785        assert_eq!(args[1], b"2");
786        assert_eq!(args[2], b"CPU");
787        assert_eq!(args[3], b"NET");
788        assert_eq!(args[4], b"COUNT");
789        assert_eq!(args[5], b"50");
790        assert_eq!(args[6], b"DURATION");
791        assert_eq!(args[7], b"120");
792        assert_eq!(args[8], b"SAMPLE");
793        assert_eq!(args[9], b"500");
794    }
795
796    #[test]
797    fn test_hotkeys_response_parsing_resp2() {
798        use crate::Value;
799
800        // Simulate RESP2 flat array response
801        let response = Value::Array(vec![
802            Value::BulkString(b"tracking-active".to_vec()),
803            Value::Int(1),
804            Value::BulkString(b"sample-ratio".to_vec()),
805            Value::Int(1),
806            Value::BulkString(b"selected-slots".to_vec()),
807            Value::Array(vec![Value::Array(vec![Value::Int(0), Value::Int(16383)])]),
808            Value::BulkString(b"all-commands-all-slots-us".to_vec()),
809            Value::Int(5000),
810            Value::BulkString(b"net-bytes-all-commands-all-slots".to_vec()),
811            Value::Int(2048),
812            Value::BulkString(b"collection-start-time-unix-ms".to_vec()),
813            Value::Int(1700000000000),
814            Value::BulkString(b"collection-duration-ms".to_vec()),
815            Value::Int(10000),
816            Value::BulkString(b"total-cpu-time-user-ms".to_vec()),
817            Value::Int(100),
818            Value::BulkString(b"total-cpu-time-sys-ms".to_vec()),
819            Value::Int(50),
820            Value::BulkString(b"by-cpu-time-us".to_vec()),
821            Value::Array(vec![
822                Value::BulkString(b"key1".to_vec()),
823                Value::Int(1500),
824                Value::BulkString(b"key2".to_vec()),
825                Value::Int(750),
826            ]),
827        ]);
828
829        let result = HotkeysResponse::from_redis_value(response).unwrap();
830
831        assert!(result.tracking_active);
832        assert_eq!(result.sample_ratio, 1);
833        assert_eq!(result.selected_slots.len(), 1);
834        assert_eq!(result.selected_slots[0].start, 0);
835        assert_eq!(result.selected_slots[0].end, 16383);
836        assert_eq!(result.all_commands_all_slots_us, 5000);
837        assert_eq!(result.net_bytes_all_commands_all_slots, 2048);
838        assert_eq!(result.collection_start_time_unix_ms, 1700000000000);
839        assert_eq!(result.collection_duration_ms, 10000);
840        assert_eq!(result.total_cpu_time_user_ms, Some(100));
841        assert_eq!(result.total_cpu_time_sys_ms, Some(50));
842
843        let cpu_keys = result.by_cpu_time_us.unwrap();
844        assert_eq!(cpu_keys.len(), 2);
845        assert_eq!(cpu_keys[0].key, "key1");
846        assert_eq!(cpu_keys[0].value, 1500);
847        assert_eq!(cpu_keys[1].key, "key2");
848        assert_eq!(cpu_keys[1].value, 750);
849    }
850
851    #[test]
852    fn test_hotkeys_response_parsing_resp3() {
853        use crate::Value;
854
855        // Simulate RESP3 map response.
856        let response = Value::Map(vec![
857            (
858                Value::BulkString(b"tracking-active".to_vec()),
859                Value::Int(1),
860            ),
861            (Value::BulkString(b"sample-ratio".to_vec()), Value::Int(1)),
862            (
863                Value::BulkString(b"selected-slots".to_vec()),
864                Value::Array(vec![Value::Array(vec![Value::Int(0), Value::Int(16383)])]),
865            ),
866            (
867                Value::BulkString(b"all-commands-all-slots-us".to_vec()),
868                Value::Int(5000),
869            ),
870            (
871                Value::BulkString(b"all-commands-selected-slots-us".to_vec()),
872                Value::Int(4000),
873            ),
874            (
875                Value::BulkString(b"net-bytes-all-commands-all-slots".to_vec()),
876                Value::Int(2048),
877            ),
878            (
879                Value::BulkString(b"net-bytes-all-commands-selected-slots".to_vec()),
880                Value::Int(1024),
881            ),
882            (
883                Value::BulkString(b"collection-start-time-unix-ms".to_vec()),
884                Value::Int(1700000000000),
885            ),
886            (
887                Value::BulkString(b"collection-duration-ms".to_vec()),
888                Value::Int(10000),
889            ),
890            (
891                Value::BulkString(b"total-cpu-time-user-ms".to_vec()),
892                Value::Int(100),
893            ),
894            (
895                Value::BulkString(b"total-cpu-time-sys-ms".to_vec()),
896                Value::Int(50),
897            ),
898            (
899                Value::BulkString(b"by-cpu-time-us".to_vec()),
900                Value::Array(vec![
901                    Value::BulkString(b"key1".to_vec()),
902                    Value::Int(1500),
903                    Value::BulkString(b"key2".to_vec()),
904                    Value::Int(750),
905                ]),
906            ),
907        ]);
908
909        let result = HotkeysResponse::from_redis_value(response).unwrap();
910
911        assert!(result.tracking_active);
912        assert_eq!(result.sample_ratio, 1);
913        assert_eq!(result.selected_slots.len(), 1);
914        assert_eq!(result.selected_slots[0].start, 0);
915        assert_eq!(result.selected_slots[0].end, 16383);
916        assert_eq!(result.all_commands_all_slots_us, 5000);
917        assert_eq!(result.all_commands_selected_slots_us, Some(4000));
918        assert_eq!(result.net_bytes_all_commands_all_slots, 2048);
919        assert_eq!(result.net_bytes_all_commands_selected_slots, Some(1024));
920        assert_eq!(result.collection_start_time_unix_ms, 1700000000000);
921        assert_eq!(result.collection_duration_ms, 10000);
922        assert_eq!(result.total_cpu_time_user_ms, Some(100));
923        assert_eq!(result.total_cpu_time_sys_ms, Some(50));
924
925        let cpu_keys = result.by_cpu_time_us.unwrap();
926        assert_eq!(cpu_keys.len(), 2);
927        assert_eq!(cpu_keys[0].key, "key1");
928        assert_eq!(cpu_keys[0].value, 1500);
929        assert_eq!(cpu_keys[1].key, "key2");
930        assert_eq!(cpu_keys[1].value, 750);
931    }
932
933    #[test]
934    fn test_hotkeys_response_parsing_with_net() {
935        use crate::Value;
936
937        let response = Value::Array(vec![
938            Value::BulkString(b"tracking-active".to_vec()),
939            Value::Int(0),
940            Value::BulkString(b"sample-ratio".to_vec()),
941            Value::Int(1),
942            Value::BulkString(b"selected-slots".to_vec()),
943            Value::Array(vec![]),
944            Value::BulkString(b"all-commands-all-slots-us".to_vec()),
945            Value::Int(0),
946            Value::BulkString(b"net-bytes-all-commands-all-slots".to_vec()),
947            Value::Int(4096),
948            Value::BulkString(b"collection-start-time-unix-ms".to_vec()),
949            Value::Int(1700000000000),
950            Value::BulkString(b"collection-duration-ms".to_vec()),
951            Value::Int(5000),
952            Value::BulkString(b"total-net-bytes".to_vec()),
953            Value::Int(8192),
954            Value::BulkString(b"by-net-bytes".to_vec()),
955            Value::Array(vec![
956                Value::BulkString(b"bigkey".to_vec()),
957                Value::Int(4096),
958                Value::BulkString(b"smallkey".to_vec()),
959                Value::Int(256),
960            ]),
961        ]);
962
963        let result = HotkeysResponse::from_redis_value(response).unwrap();
964
965        assert!(!result.tracking_active);
966        assert_eq!(result.total_net_bytes, Some(8192));
967
968        let net_keys = result.by_net_bytes.unwrap();
969        assert_eq!(net_keys.len(), 2);
970        assert_eq!(net_keys[0].key, "bigkey");
971        assert_eq!(net_keys[0].value, 4096);
972        assert_eq!(net_keys[1].key, "smallkey");
973        assert_eq!(net_keys[1].value, 256);
974    }
975
976    #[test]
977    fn test_hotkeys_response_nil() {
978        use crate::Value;
979        use crate::types::from_redis_value;
980
981        // Redis returns `Nil` for HOTKEYS GET when there is no active tracking
982        // session (e.g. never started, stopped, or reset). `hotkeys_get` decodes
983        // into `Option<HotkeysResponse>`, so `Nil` must surface as `None` rather
984        // than a default-constructed response.
985        let response = Value::Nil;
986        let result: Option<HotkeysResponse> = from_redis_value(response).unwrap();
987        assert!(result.is_none());
988    }
989}