ktstr/scenario/snapshot/
entry.rs

1//! [`SnapshotEntry`] — one entry's view across a `FailureDumpMap`
2//! variant. Carries the rendered key (when BTF was present at
3//! capture) plus accessors for typed reads, per-CPU narrowing, and
4//! aggregation across per-CPU slots.
5
6use crate::monitor::btf_render::RenderedValue;
7use crate::monitor::dump::{FailureDumpEntry, FailureDumpPercpuEntry, FailureDumpPercpuHashEntry};
8
9use super::{SnapshotError, SnapshotField, SnapshotResult, walk_dotted_path};
10
11/// One entry's view — either a HASH (key, value) pair, a per-CPU
12/// array entry, a per-CPU hash entry, a single rendered value, or
13/// a missing-entry marker.
14#[derive(Debug)]
15#[must_use = "SnapshotEntry is a borrowed view; chain accessors"]
16#[non_exhaustive]
17pub enum SnapshotEntry<'a> {
18    /// HASH map entry — `(key, value)` pair.
19    Hash(&'a FailureDumpEntry),
20    /// PERCPU_ARRAY entry — outer u32 key, inner per-CPU vec.
21    Percpu(&'a FailureDumpPercpuEntry),
22    /// PERCPU_HASH entry — rendered key, inner per-CPU vec.
23    PercpuHash(&'a FailureDumpPercpuHashEntry),
24    /// Single rendered value (ARRAY map's `value` field, or a
25    /// per-CPU slot resolved via [`super::SnapshotMap::cpu`]).
26    Value(&'a RenderedValue),
27    /// No entry matched.
28    Missing(SnapshotError),
29}
30
31impl<'a> SnapshotEntry<'a> {
32    /// True when the lookup succeeded.
33    pub fn is_present(&self) -> bool {
34        !matches!(self, SnapshotEntry::Missing(_))
35    }
36
37    /// Walk into the entry's value side along a dotted path. Each
38    /// path component names a [`RenderedValue::Struct`] member;
39    /// pointer dereferences are followed transparently. Returns
40    /// [`SnapshotField::Missing`] with an actionable error
41    /// when the path cannot be resolved.
42    pub fn get(&self, path: &str) -> SnapshotField<'a> {
43        let value = match self {
44            SnapshotEntry::Hash(e) => e.value.as_ref(),
45            SnapshotEntry::Percpu(_) | SnapshotEntry::PercpuHash(_) => {
46                let map_name = match self {
47                    SnapshotEntry::Percpu(_) => "<percpu-array>".to_string(),
48                    SnapshotEntry::PercpuHash(_) => "<percpu-hash>".to_string(),
49                    _ => String::new(),
50                };
51                return SnapshotField::Missing(SnapshotError::PerCpuNotNarrowed { map: map_name });
52            }
53            SnapshotEntry::Value(v) => Some(*v),
54            SnapshotEntry::Missing(err) => {
55                return SnapshotField::Missing(err.clone());
56            }
57        };
58        let Some(v) = value else {
59            return SnapshotField::Missing(SnapshotError::NoRendered {
60                map: "<entry>".to_string(),
61                side: "value".to_string(),
62            });
63        };
64        walk_dotted_path(v, path)
65    }
66
67    /// Look up the entry's KEY side along a dotted path. Mirror
68    /// of [`Self::get`] but operates on the key's rendered
69    /// structure. Supports the three key-bearing variants: `Hash`
70    /// and `PercpuHash` walk their rendered key; `Percpu` returns
71    /// its u32 key as [`SnapshotField::PercpuKey`] for an empty
72    /// path (and `TypeMismatch` for a non-empty path).
73    pub fn key(&self, path: &str) -> SnapshotField<'a> {
74        match self {
75            SnapshotEntry::Hash(e) => match e.key.as_ref() {
76                Some(v) => walk_dotted_path(v, path),
77                None => SnapshotField::Missing(SnapshotError::NoRendered {
78                    map: "<entry>".to_string(),
79                    side: "key".to_string(),
80                }),
81            },
82            SnapshotEntry::PercpuHash(e) => match e.key.as_ref() {
83                Some(v) => walk_dotted_path(v, path),
84                None => SnapshotField::Missing(SnapshotError::NoRendered {
85                    map: "<entry>".to_string(),
86                    side: "key".to_string(),
87                }),
88            },
89            SnapshotEntry::Percpu(e) => {
90                if path.is_empty() {
91                    SnapshotField::PercpuKey { key: e.key }
92                } else {
93                    SnapshotField::Missing(SnapshotError::TypeMismatch {
94                        expected: "Struct".to_string(),
95                        actual: "Uint(percpu key)".to_string(),
96                        requested: path.to_string(),
97                    })
98                }
99            }
100            SnapshotEntry::Value(_) => SnapshotField::Missing(SnapshotError::TypeMismatch {
101                expected: "key".to_string(),
102                actual: "single Value (no key)".to_string(),
103                requested: path.to_string(),
104            }),
105            SnapshotEntry::Missing(err) => SnapshotField::Missing(err.clone()),
106        }
107    }
108
109    // -----------------------------------------------------------------
110    // Per-CPU aggregators. Apply only to `Percpu` / `PercpuHash`
111    // entries; other variants return an `Err`: `Hash` / `Value`
112    // yield `TypeMismatch`, `Missing` propagates its captured error.
113    // Inside the
114    // per_cpu vec, slots whose value is `None` (CPU unmapped / out of
115    // range — see `read_percpu_array_value` semantics) skip the
116    // aggregation; slots whose rendered value can't decode to the
117    // requested scalar return `Err(TypeMismatch)` immediately.
118    //
119    // `cpu_sum_*`, `cpu_max_*`, and `cpu_min_*` all return
120    // `Err(NoMatch)` when no slot contributes (every slot `None`). A
121    // `None` slot is UNREADABLE (host-read failure), NOT a real zero,
122    // so an all-None sum of `0` would silently drop the missing data;
123    // a readable map whose slots are all `0` still sums to `Ok(0)`.
124    // -----------------------------------------------------------------
125
126    /// Sum the per-CPU values at `path` as `u64`. Returns
127    /// `Err(NoMatch)` when every slot is `None`: a `None` slot is
128    /// UNREADABLE (host-read failure), not a real zero, so an all-None
129    /// sum of `0` would silently drop the unreadable data. A readable
130    /// map whose slots are all `0` still sums to `Ok(0)`. A slot whose
131    /// rendered value cannot decode to `u64` propagates an Err
132    /// immediately and stops the aggregation.
133    pub fn cpu_sum_u64(&self, path: &str) -> SnapshotResult<u64> {
134        let mut acc: u64 = 0;
135        let mut contributed = false;
136        self.try_for_each_cpu_value(path, |v| {
137            acc = acc.saturating_add(SnapshotField::Value(v).as_u64()?);
138            contributed = true;
139            Ok(())
140        })?;
141        if contributed {
142            Ok(acc)
143        } else {
144            Err(self.empty_aggregate_error("cpu_sum_u64"))
145        }
146    }
147
148    /// Maximum of per-CPU values at `path` as `u64`. Returns
149    /// `Err(NoMatch)` when every slot is `None` (no slot contributed).
150    /// A slot whose rendered value cannot decode to `u64` propagates
151    /// an Err immediately.
152    pub fn cpu_max_u64(&self, path: &str) -> SnapshotResult<u64> {
153        let mut best: Option<u64> = None;
154        self.try_for_each_cpu_value(path, |v| {
155            let n = SnapshotField::Value(v).as_u64()?;
156            best = Some(best.map_or(n, |b| b.max(n)));
157            Ok(())
158        })?;
159        best.ok_or_else(|| self.empty_aggregate_error("cpu_max_u64"))
160    }
161
162    /// Minimum of per-CPU values at `path` as `u64`. Returns
163    /// `Err(NoMatch)` when every slot is `None`. A slot whose
164    /// rendered value cannot decode to `u64` propagates an Err
165    /// immediately.
166    pub fn cpu_min_u64(&self, path: &str) -> SnapshotResult<u64> {
167        let mut best: Option<u64> = None;
168        self.try_for_each_cpu_value(path, |v| {
169            let n = SnapshotField::Value(v).as_u64()?;
170            best = Some(best.map_or(n, |b| b.min(n)));
171            Ok(())
172        })?;
173        best.ok_or_else(|| self.empty_aggregate_error("cpu_min_u64"))
174    }
175
176    /// Sum the per-CPU values at `path` as `i64`. Returns
177    /// `Err(NoMatch)` when every slot is `None`, matching
178    /// [`Self::cpu_sum_u64`]: a `None` slot is UNREADABLE, not a real
179    /// zero, so an all-None sum of `0` would be a silent drop. A
180    /// readable all-zero map still sums to `Ok(0)`. The sum saturates
181    /// at `i64::MIN` / `i64::MAX`. A slot whose rendered value cannot
182    /// decode to `i64` propagates an Err immediately and stops the
183    /// aggregation.
184    pub fn cpu_sum_i64(&self, path: &str) -> SnapshotResult<i64> {
185        let mut acc: i64 = 0;
186        let mut contributed = false;
187        self.try_for_each_cpu_value(path, |v| {
188            acc = acc.saturating_add(SnapshotField::Value(v).as_i64()?);
189            contributed = true;
190            Ok(())
191        })?;
192        if contributed {
193            Ok(acc)
194        } else {
195            Err(self.empty_aggregate_error("cpu_sum_i64"))
196        }
197    }
198
199    /// Maximum of per-CPU values at `path` as `i64`. Returns
200    /// `Err(NoMatch)` when every slot is `None` (no slot contributed).
201    /// A slot whose rendered value cannot decode to `i64` propagates
202    /// an Err immediately.
203    pub fn cpu_max_i64(&self, path: &str) -> SnapshotResult<i64> {
204        let mut best: Option<i64> = None;
205        self.try_for_each_cpu_value(path, |v| {
206            let n = SnapshotField::Value(v).as_i64()?;
207            best = Some(best.map_or(n, |b| b.max(n)));
208            Ok(())
209        })?;
210        best.ok_or_else(|| self.empty_aggregate_error("cpu_max_i64"))
211    }
212
213    /// Minimum of per-CPU values at `path` as `i64`. Returns
214    /// `Err(NoMatch)` when every slot is `None`. A slot whose
215    /// rendered value cannot decode to `i64` propagates an Err
216    /// immediately.
217    pub fn cpu_min_i64(&self, path: &str) -> SnapshotResult<i64> {
218        let mut best: Option<i64> = None;
219        self.try_for_each_cpu_value(path, |v| {
220            let n = SnapshotField::Value(v).as_i64()?;
221            best = Some(best.map_or(n, |b| b.min(n)));
222            Ok(())
223        })?;
224        best.ok_or_else(|| self.empty_aggregate_error("cpu_min_i64"))
225    }
226
227    /// Sum the per-CPU values at `path` as `f64`. Returns
228    /// `Err(NoMatch)` when every slot is `None`: a `None` slot is
229    /// UNREADABLE, not a real zero, so an all-None sum of `0.0` would
230    /// be a silent drop. A readable all-zero map still sums to
231    /// `Ok(0.0)`. A slot whose rendered value cannot decode to `f64`
232    /// propagates an Err immediately. NaN slot values propagate through
233    /// `+=` per IEEE-754 — a single NaN slot makes the result NaN.
234    pub fn cpu_sum_f64(&self, path: &str) -> SnapshotResult<f64> {
235        let mut acc: f64 = 0.0;
236        let mut contributed = false;
237        self.try_for_each_cpu_value(path, |v| {
238            acc += SnapshotField::Value(v).as_f64()?;
239            contributed = true;
240            Ok(())
241        })?;
242        if contributed {
243            Ok(acc)
244        } else {
245            Err(self.empty_aggregate_error("cpu_sum_f64"))
246        }
247    }
248
249    /// Maximum of per-CPU values at `path` as `f64`. Returns
250    /// `Err(NoMatch)` when every slot is `None`. A slot whose
251    /// rendered value cannot decode to `f64` propagates an Err
252    /// immediately. NaN slot values are filtered out per
253    /// `f64::max` semantics — `f64::max(NaN, x)` returns `x`, so a
254    /// NaN slot never wins against a non-NaN slot. An all-NaN run
255    /// is an edge case: the first NaN slot sets `best=NaN`, then
256    /// subsequent `NaN.max(NaN)` returns NaN, so the final result
257    /// is `Ok(NaN)` rather than NoMatch.
258    pub fn cpu_max_f64(&self, path: &str) -> SnapshotResult<f64> {
259        let mut best: Option<f64> = None;
260        self.try_for_each_cpu_value(path, |v| {
261            let n = SnapshotField::Value(v).as_f64()?;
262            best = Some(best.map_or(n, |b| b.max(n)));
263            Ok(())
264        })?;
265        best.ok_or_else(|| self.empty_aggregate_error("cpu_max_f64"))
266    }
267
268    /// Minimum of per-CPU values at `path` as `f64`. Returns
269    /// `Err(NoMatch)` when every slot is `None`. A slot whose
270    /// rendered value cannot decode to `f64` propagates an Err
271    /// immediately. NaN slot values are filtered out per
272    /// `f64::min` semantics — `f64::min(NaN, x)` returns `x`, so a
273    /// NaN slot never wins against a non-NaN slot. An all-NaN run
274    /// yields `Ok(NaN)` rather than NoMatch — same edge case as
275    /// `cpu_max_f64`.
276    pub fn cpu_min_f64(&self, path: &str) -> SnapshotResult<f64> {
277        let mut best: Option<f64> = None;
278        self.try_for_each_cpu_value(path, |v| {
279            let n = SnapshotField::Value(v).as_f64()?;
280            best = Some(best.map_or(n, |b| b.min(n)));
281            Ok(())
282        })?;
283        best.ok_or_else(|| self.empty_aggregate_error("cpu_min_f64"))
284    }
285
286    /// Iterate non-None per-CPU rendered values at `path`. For each
287    /// successful slot, invokes `f(cpu_idx, &RenderedValue)`. Slots
288    /// whose value is `None` are skipped silently; the iteration
289    /// stops at the first slot whose value cannot be reached via
290    /// `path` (returning the path-walk error). Returns `Err` for
291    /// non-percpu variants.
292    pub fn cpu_each<F>(&self, path: &str, mut f: F) -> SnapshotResult<()>
293    where
294        F: FnMut(usize, &'a RenderedValue) -> SnapshotResult<()>,
295    {
296        let per_cpu: &[Option<RenderedValue>] = match self {
297            SnapshotEntry::Percpu(e) => &e.per_cpu,
298            SnapshotEntry::PercpuHash(e) => &e.per_cpu,
299            SnapshotEntry::Hash(_) | SnapshotEntry::Value(_) => {
300                return Err(SnapshotError::TypeMismatch {
301                    expected: "Percpu / PercpuHash".to_string(),
302                    actual: self.variant_name().to_string(),
303                    requested: path.to_string(),
304                });
305            }
306            SnapshotEntry::Missing(err) => return Err(err.clone()),
307        };
308        for (cpu_idx, slot) in per_cpu.iter().enumerate() {
309            let Some(rendered) = slot.as_ref() else {
310                continue;
311            };
312            let walked = walk_dotted_path(rendered, path);
313            let value = match walked {
314                SnapshotField::Value(v) => v,
315                SnapshotField::PercpuKey { .. } => {
316                    return Err(SnapshotError::TypeMismatch {
317                        expected: "rendered value".to_string(),
318                        actual: "PercpuKey".to_string(),
319                        requested: path.to_string(),
320                    });
321                }
322                SnapshotField::Missing(err) => return Err(err),
323            };
324            f(cpu_idx, value)?;
325        }
326        Ok(())
327    }
328
329    /// Shared walk helper for `cpu_sum_*` / `cpu_max_*` / `cpu_min_*`
330    /// — invokes `f` on every non-None slot's rendered value.
331    fn try_for_each_cpu_value<F>(&self, path: &str, mut f: F) -> SnapshotResult<()>
332    where
333        F: FnMut(&'a RenderedValue) -> SnapshotResult<()>,
334    {
335        self.cpu_each(path, |_, v| f(v))
336    }
337
338    /// Name for diagnostic messages. Used by the per-CPU aggregator
339    /// `TypeMismatch` paths so the error names the actual variant.
340    fn variant_name(&self) -> &'static str {
341        match self {
342            SnapshotEntry::Hash(_) => "Hash",
343            SnapshotEntry::Percpu(_) => "Percpu",
344            SnapshotEntry::PercpuHash(_) => "PercpuHash",
345            SnapshotEntry::Value(_) => "Value",
346            SnapshotEntry::Missing(_) => "Missing",
347        }
348    }
349
350    /// Build the `NoMatch` error for an empty per-CPU aggregate
351    /// (max / min of all-None or all-decode-fail). `op` names the
352    /// caller so the error message points at the right method.
353    fn empty_aggregate_error(&self, op: &str) -> SnapshotError {
354        SnapshotError::NoMatch {
355            map: format!("<{}>", self.variant_name()),
356            op: op.to_string(),
357            len: 0,
358            available_keys: Vec::new(),
359        }
360    }
361}
362
363// ---------------------------------------------------------------------------
364// SnapshotField — terminal traversal value