ktstr/scenario/snapshot/pickers.rs
1//! Predefined disambiguator closures for
2//! [`Snapshot::live_var_via`](super::view::Snapshot::live_var_via).
3//!
4//! Pickers are plain functions matching the closure shape
5//! `live_var_via`'s `picker` arg expects (`FnOnce(&[(&str,
6//! SnapshotField)]) -> Option<usize>`). Pass by name:
7//!
8//! ```ignore
9//! snap.live_var_via("nr_dispatched", pickers::max_by_counter_value)
10//! ```
11//!
12//! Every picker baked into this module is a HEURISTIC for some
13//! "live instance vs prior instance" question; the picker NAME
14//! tells you what operation it performs (not what semantic the
15//! caller can conclude). Choose deliberately — the caller is
16//! responsible for picking a picker whose operation matches the
17//! metric's shape (counter vs gauge vs sentinel) and the test's
18//! timing relative to scheduler swaps.
19
20use super::SnapshotField;
21
22/// Pick the candidate whose `as_u64()` value is largest. The
23/// operation is purely mechanical: project each candidate via
24/// `SnapshotField::as_u64`, drop those that fail to project
25/// (wrong type, missing field, etc.), and return the index of
26/// the surviving candidate with the largest u64.
27///
28/// # Active-bss heuristic
29///
30/// For a cumulative non-decreasing counter where the inactive
31/// instance's bss stays at BSS-zero init (e.g. the just-killed
32/// scheduler's `bpf_bpf.bss` copy after `Op::ReplaceScheduler`),
33/// the live instance has by definition accumulated more events,
34/// so max-by-value reliably picks the live bss copy. This is the
35/// load-bearing usage pattern multi-instance A/B tests reach for.
36///
37/// # When this picks WRONG
38///
39/// Treat the picker as "max by u64," not as "the live instance":
40///
41/// - **Gauges or stateful fields.** An inactive copy can carry a
42/// sticky non-zero value from before detach (e.g. a high-water
43/// mark, a last-seen-timestamp). Max-by-value would still pick
44/// it after the live instance starts from zero.
45/// - **Sentinel-init counters** (e.g. `u64::MAX` on init meaning
46/// "unset"). Max is meaningless here — the uninitialized copy
47/// wins every comparison.
48/// - **Immediately-post-swap reads** before the new instance has
49/// any events. The just-killed copy still has the larger
50/// cumulative count for some window after the swap. For
51/// `Op::ReplaceScheduler` races the new
52/// `wait_for_accessor_publish_advance` gate already serializes
53/// most of that window away, but a snapshot fired before the
54/// first post-swap counter increment will still observe the
55/// old instance's cumulative value as the maximum.
56///
57/// For any of these, write a picker that names the live instance
58/// explicitly — by binary fingerprint, by obj-name prefix
59/// equality, or by a separately captured liveness signal — and
60/// pass it to `live_var_via` instead.
61///
62/// # Return semantic
63///
64/// Returns the index of the max candidate, or `None` if every
65/// candidate failed to project to `u64` (in which case
66/// `live_var_via` surfaces a
67/// [`SnapshotError::ProjectionFailed`](super::SnapshotError::ProjectionFailed)
68/// naming the picker as the source). Ties resolve to the LAST
69/// tied index (deterministic; matches `Iterator::max_by_key`'s
70/// "last element of equal-maximum group wins" rule). Test
71/// authors who need a different tie-break write a sibling picker
72/// rather than relying on a specific ordering rule that's not
73/// load-bearing for the active-bss heuristic this picker targets.
74pub fn max_by_counter_value(fields: &[(&str, SnapshotField<'_>)]) -> Option<usize> {
75 fields
76 .iter()
77 .enumerate()
78 .filter_map(|(i, (_, f))| Some((i, f.as_u64().ok()?)))
79 .max_by_key(|(_, v)| *v)
80 .map(|(i, _)| i)
81}
82
83/// Pick the candidate row whose fields sum to the largest u64 — the
84/// "max-activity bss" heuristic generalized to N co-picked variables.
85/// For each row, the picker `as_u64`-projects every field and
86/// `saturating_add`s them; rows containing ANY field that fails to
87/// project to u64 are dropped entirely (not partial-summed); among
88/// the surviving rows, the one with the largest sum wins.
89///
90/// Pairs with [`super::view::Snapshot::live_vars_via`] for ratio /
91/// fraction metrics: the row whose counters have accumulated the
92/// most TOTAL events is the live scheduler instance, and selecting
93/// that row guarantees all N returned fields come from the same
94/// source map (no cross-bss-copy corruption).
95///
96/// # When this picks WRONG
97///
98/// Inherits every failure mode of [`max_by_counter_value`] (gauges,
99/// sentinel-init counters like `u64::MAX`, immediately-post-swap
100/// windows before the new instance has accumulated past the old's
101/// BSS-zero point), plus one sum-specific mode:
102///
103/// **Any-non-u64-field row excluded.** A single rodata or
104/// non-counter variable accidentally included in the name set
105/// silently makes every candidate row ineligible (every row would
106/// have that non-u64 field). Verify all N names project to u64
107/// counters BEFORE composing the picker.
108///
109/// Sums use `saturating_add`: a row containing a sentinel
110/// `u64::MAX` saturates to `u64::MAX` and wins all comparisons.
111/// Treat that as a sentinel-mode failure (per max_by_counter_value's
112/// caveat).
113///
114/// Ties resolve to the LAST tied row (matches
115/// [`Iterator::max_by_key`] semantics — same as
116/// [`max_by_counter_value`]).
117pub fn max_by_sum_u64(rows: &[(&str, Vec<SnapshotField<'_>>)]) -> Option<usize> {
118 rows.iter()
119 .enumerate()
120 .filter_map(|(i, (_, fields))| {
121 let mut sum: u64 = 0;
122 for f in fields {
123 let v = f.as_u64().ok()?;
124 sum = sum.saturating_add(v);
125 }
126 Some((i, sum))
127 })
128 .max_by_key(|(_, s)| *s)
129 .map(|(i, _)| i)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::monitor::btf_render::RenderedValue;
136
137 fn u64_field(value: u64) -> RenderedValue {
138 RenderedValue::Uint { bits: 64, value }
139 }
140
141 fn struct_field() -> RenderedValue {
142 RenderedValue::Struct {
143 type_name: Some("opaque".into()),
144 members: vec![],
145 }
146 }
147
148 #[test]
149 fn max_by_counter_value_picks_largest_u64() {
150 let a = u64_field(10);
151 let b = u64_field(100);
152 let c = u64_field(5);
153 let fields = vec![
154 ("alpha.bss", SnapshotField::Value(&a)),
155 ("beta.bss", SnapshotField::Value(&b)),
156 ("gamma.bss", SnapshotField::Value(&c)),
157 ];
158 assert_eq!(max_by_counter_value(&fields), Some(1));
159 }
160
161 #[test]
162 fn max_by_counter_value_skips_non_u64_picks_max_from_remainder() {
163 let a = u64_field(10);
164 let s = struct_field();
165 let c = u64_field(100);
166 let fields = vec![
167 ("alpha.bss", SnapshotField::Value(&a)),
168 ("beta.bss", SnapshotField::Value(&s)),
169 ("gamma.bss", SnapshotField::Value(&c)),
170 ];
171 assert_eq!(
172 max_by_counter_value(&fields),
173 Some(2),
174 "Struct candidate drops out via as_u64 error; max of \
175 {{alpha=10, gamma=100}} is gamma at index 2",
176 );
177 }
178
179 #[test]
180 fn max_by_counter_value_returns_none_when_all_candidates_non_u64() {
181 let s1 = struct_field();
182 let s2 = struct_field();
183 let fields = vec![
184 ("alpha.bss", SnapshotField::Value(&s1)),
185 ("beta.bss", SnapshotField::Value(&s2)),
186 ];
187 assert_eq!(
188 max_by_counter_value(&fields),
189 None,
190 "every candidate fails as_u64 — picker returns None so \
191 live_var_via surfaces ProjectionFailed naming the picker",
192 );
193 }
194
195 #[test]
196 fn max_by_counter_value_tie_picks_last() {
197 let a = u64_field(50);
198 let b = u64_field(50);
199 let c = u64_field(50);
200 let fields = vec![
201 ("alpha.bss", SnapshotField::Value(&a)),
202 ("beta.bss", SnapshotField::Value(&b)),
203 ("gamma.bss", SnapshotField::Value(&c)),
204 ];
205 assert_eq!(
206 max_by_counter_value(&fields),
207 Some(2),
208 "Iterator::max_by_key returns the LAST element of an \
209 equal-maximum group; picker tie-break is deterministic \
210 across runs but lands on the last tied index, not the \
211 first",
212 );
213 }
214
215 #[test]
216 fn max_by_counter_value_empty_input_returns_none() {
217 let fields: Vec<(&str, SnapshotField<'_>)> = vec![];
218 assert_eq!(max_by_counter_value(&fields), None);
219 }
220
221 // ---------- max_by_sum_u64 ----------
222
223 #[test]
224 fn max_by_sum_u64_picks_largest_summed_row() {
225 let a1 = u64_field(10);
226 let a2 = u64_field(20);
227 let b1 = u64_field(5);
228 let b2 = u64_field(5);
229 let c1 = u64_field(30);
230 let c2 = u64_field(40);
231 let rows = vec![
232 (
233 "alpha.bss",
234 vec![SnapshotField::Value(&a1), SnapshotField::Value(&a2)],
235 ),
236 (
237 "beta.bss",
238 vec![SnapshotField::Value(&b1), SnapshotField::Value(&b2)],
239 ),
240 (
241 "gamma.bss",
242 vec![SnapshotField::Value(&c1), SnapshotField::Value(&c2)],
243 ),
244 ];
245 assert_eq!(
246 max_by_sum_u64(&rows),
247 Some(2),
248 "row 2 sum = 70, beats row 0 (30) and row 1 (10)",
249 );
250 }
251
252 #[test]
253 fn max_by_sum_u64_tie_picks_last() {
254 let v = u64_field(10);
255 let rows = vec![
256 (
257 "a",
258 vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
259 ),
260 (
261 "b",
262 vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
263 ),
264 (
265 "c",
266 vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
267 ),
268 ];
269 assert_eq!(
270 max_by_sum_u64(&rows),
271 Some(2),
272 "Iterator::max_by_key returns the LAST tied element; \
273 matches max_by_counter_value semantic for caller \
274 consistency",
275 );
276 }
277
278 #[test]
279 fn max_by_sum_u64_drops_rows_with_any_non_u64_field() {
280 let u = u64_field(10);
281 let s = struct_field();
282 let big = u64_field(1000);
283 let rows = vec![
284 // row 0: u64 + Struct → drop entirely (any non-u64 → ineligible)
285 (
286 "alpha",
287 vec![SnapshotField::Value(&u), SnapshotField::Value(&s)],
288 ),
289 // row 1: u64 + u64 → eligible, sum = 1010
290 (
291 "beta",
292 vec![SnapshotField::Value(&u), SnapshotField::Value(&big)],
293 ),
294 // row 2: Struct + u64 → drop entirely
295 (
296 "gamma",
297 vec![SnapshotField::Value(&s), SnapshotField::Value(&big)],
298 ),
299 ];
300 assert_eq!(
301 max_by_sum_u64(&rows),
302 Some(1),
303 "only row 1 survives the any-non-u64-eligibility filter; \
304 picker is NOT partial-summing (Struct + u64 doesn't fall \
305 back to summing the u64 alone)",
306 );
307 }
308
309 #[test]
310 fn max_by_sum_u64_all_rows_have_non_u64_field_returns_none() {
311 let u = u64_field(10);
312 let s = struct_field();
313 let rows = vec![
314 (
315 "alpha",
316 vec![SnapshotField::Value(&u), SnapshotField::Value(&s)],
317 ),
318 (
319 "beta",
320 vec![SnapshotField::Value(&s), SnapshotField::Value(&u)],
321 ),
322 ];
323 assert_eq!(
324 max_by_sum_u64(&rows),
325 None,
326 "every row has at least one non-u64 field; downstream \
327 live_vars_via surfaces ProjectionFailed",
328 );
329 }
330
331 #[test]
332 fn max_by_sum_u64_empty_rows_returns_none() {
333 let rows: Vec<(&str, Vec<SnapshotField<'_>>)> = vec![];
334 assert_eq!(max_by_sum_u64(&rows), None);
335 }
336
337 #[test]
338 fn max_by_sum_u64_saturates_on_overflow() {
339 let big = u64_field(u64::MAX);
340 let one = u64_field(1);
341 let rows = vec![
342 (
343 "alpha",
344 vec![SnapshotField::Value(&big), SnapshotField::Value(&one)],
345 ),
346 (
347 "beta",
348 vec![SnapshotField::Value(&one), SnapshotField::Value(&one)],
349 ),
350 ];
351 // alpha saturates to u64::MAX; beta sums to 2. Alpha wins.
352 assert_eq!(max_by_sum_u64(&rows), Some(0));
353 }
354}