use crate::assert::temporal::SeriesField;
use crate::scenario::snapshot::{JsonField, SnapshotResult, stats_path};
use super::{SampleSeries, build_series_field};
impl SampleSeries {
pub fn stats<T, F>(&self, label: impl Into<String>, project: F) -> SeriesField<T>
where
F: Fn(StatsValue<'_>) -> SnapshotResult<T>,
{
build_series_field(&self.rows, label, |row| match row.stats.as_ref() {
Ok(v) => project(StatsValue { value: v }),
Err(reason) => Err(crate::scenario::snapshot::SnapshotError::MissingStats {
tag: row.tag.clone(),
reason: reason.clone(),
}),
})
}
pub fn stats_live_u64(&self, path: &str) -> SeriesField<u64> {
let path_owned = path.to_string();
self.stats(path_owned.clone(), move |s| s.get(&path_owned).as_u64())
}
pub fn stats_live_i64(&self, path: &str) -> SeriesField<i64> {
let path_owned = path.to_string();
self.stats(path_owned.clone(), move |s| s.get(&path_owned).as_i64())
}
pub fn stats_live_f64(&self, path: &str) -> SeriesField<f64> {
let path_owned = path.to_string();
self.stats(path_owned.clone(), move |s| s.get(&path_owned).as_f64())
}
pub fn stats_path<'a>(&'a self, path: &str) -> StatsPathProjector<'a> {
StatsPathProjector {
series: self,
path: path.to_string(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct StatsValue<'a> {
value: &'a serde_json::Value,
}
impl<'a> StatsValue<'a> {
pub fn raw(&self) -> &'a serde_json::Value {
self.value
}
pub fn get(&self, path: &str) -> JsonField<'a> {
stats_path(self.value, path)
}
}
pub struct StatsPathProjector<'a> {
series: &'a SampleSeries,
path: String,
}
impl<'a> StatsPathProjector<'a> {
pub fn field_u64(&self, key: &str) -> SeriesField<u64> {
let full_path = join_paths(&self.path, key);
self.series
.stats(key, move |sv| sv.get(&full_path).as_u64())
}
pub fn field_i64(&self, key: &str) -> SeriesField<i64> {
let full_path = join_paths(&self.path, key);
self.series
.stats(key, move |sv| sv.get(&full_path).as_i64())
}
pub fn field_f64(&self, key: &str) -> SeriesField<f64> {
let full_path = join_paths(&self.path, key);
self.series
.stats(key, move |sv| sv.get(&full_path).as_f64())
}
pub fn key(&self, key: &str) -> StatsPathProjector<'a> {
StatsPathProjector {
series: self.series,
path: join_paths(&self.path, key),
}
}
pub fn key_names(&self) -> Vec<String> {
let row = match self.series.rows.first() {
Some(r) => r,
None => return Vec::new(),
};
let stats = match row.stats.as_ref() {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let resolved = stats_path(stats, &self.path);
let raw = match resolved.raw() {
Some(v) => v,
None => return Vec::new(),
};
match raw {
serde_json::Value::Object(map) => {
let mut names: Vec<String> = map.keys().cloned().collect();
names.sort();
names
}
_ => Vec::new(),
}
}
pub fn u64_fields(&self) -> Vec<(String, SeriesField<u64>)> {
self.key_names()
.into_iter()
.filter_map(|name| {
let field = self.field_u64(&name);
let any_ok = field.values_iter().any(|r| r.is_ok());
any_ok.then_some((name, field))
})
.collect()
}
pub fn f64_fields(&self) -> Vec<(String, SeriesField<f64>)> {
self.key_names()
.into_iter()
.filter_map(|name| {
let field = self.field_f64(&name);
let any_ok = field.values_iter().any(|r| r.is_ok());
any_ok.then_some((name, field))
})
.collect()
}
}
fn join_paths(base: &str, leaf: &str) -> String {
if base.is_empty() {
leaf.to_string()
} else if leaf.is_empty() {
base.to_string()
} else {
format!("{base}.{leaf}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monitor::btf_render::{RenderedMember, RenderedValue};
use crate::monitor::dump::{FailureDumpMap, FailureDumpReport, SCHEMA_SINGLE};
fn synthetic_report(value: u64) -> FailureDumpReport {
let bss_value = RenderedValue::Struct {
type_name: Some(".bss".into()),
members: vec![
RenderedMember {
name: "nr_dispatched".into(),
value: RenderedValue::Uint { bits: 64, value },
},
RenderedMember {
name: "stall".into(),
value: RenderedValue::Uint { bits: 8, value: 0 },
},
],
};
let bss_map = FailureDumpMap {
name: "scx_obj.bss".into(),
map_kva: 0,
map_type: 2,
value_size: 16,
max_entries: 1,
value: Some(bss_value),
entries: Vec::new(),
percpu_entries: Vec::new(),
percpu_hash_entries: Vec::new(),
arena: None,
ringbuf: None,
stack_trace: None,
fd_array: None,
error: None,
};
FailureDumpReport {
schema: SCHEMA_SINGLE.to_string(),
active_map_kvas: Vec::new(),
maps: vec![bss_map],
..Default::default()
}
}
fn synthetic_stats(busy: f64) -> serde_json::Value {
serde_json::json!({
"busy": busy,
"antistall": 0,
"layers": {
"batch": { "util": busy * 0.5 }
}
})
}
fn mixed_stats(busy: u64, count: u64) -> serde_json::Value {
serde_json::json!({
"busy": busy,
"count": count,
"ratio": 0.5,
"name": "nope",
})
}
#[test]
fn stats_projection_handles_missing_stats_as_error() {
use crate::scenario::snapshot::{DrainedSnapshotEntry, MissingStatsReason};
let drained = vec![
DrainedSnapshotEntry {
tag: "periodic_000".to_string(),
report: synthetic_report(10),
stats: Ok(synthetic_stats(50.0)),
elapsed_ms: Some(100),
step_index: None,
},
DrainedSnapshotEntry {
tag: "periodic_001".to_string(),
report: synthetic_report(20),
stats: Err(MissingStatsReason::NoSchedulerBinary),
elapsed_ms: Some(200),
step_index: None,
},
];
let series = SampleSeries::from_drained_typed(drained, None);
let field: SeriesField<f64> = series.stats("busy", |s| s.get("busy").as_f64());
let outcomes: Vec<SnapshotResult<f64>> = field.values_iter().cloned().collect();
assert_eq!(outcomes.len(), 2);
assert_eq!(
outcomes[0].as_ref().copied(),
Ok(50.0),
"sample with stats present must project the `busy` field verbatim"
);
match &outcomes[1] {
Err(crate::scenario::snapshot::SnapshotError::MissingStats { tag, reason }) => {
assert_eq!(
tag, "periodic_001",
"MissingStats tag must identify the sample whose stats slot was Err"
);
assert_eq!(
reason,
&MissingStatsReason::NoSchedulerBinary,
"MissingStats reason must propagate the carried MissingStatsReason verbatim"
);
}
other => panic!(
"sample with stats=Err must surface SnapshotError::MissingStats, got {other:?}"
),
}
}
#[test]
fn stats_path_projector_field_f64_extracts_root_scalar() {
let drained = vec![
(
"periodic_000".to_string(),
synthetic_report(0),
Some(synthetic_stats(50.0)),
Some(100),
),
(
"periodic_001".to_string(),
synthetic_report(0),
Some(synthetic_stats(60.0)),
Some(200),
),
];
let series = SampleSeries::from_drained(drained, None);
let field = series.stats_path("").field_f64("busy");
let values: Vec<f64> = field
.values_iter()
.filter_map(|v| v.as_ref().ok().copied())
.collect();
assert_eq!(values.len(), 2);
assert!((values[0] - 50.0).abs() < f64::EPSILON);
assert!((values[1] - 60.0).abs() < f64::EPSILON);
}
#[test]
fn stats_path_projector_key_names_at_root() {
let drained = vec![(
"periodic_000".to_string(),
synthetic_report(0),
Some(synthetic_stats(50.0)),
Some(100),
)];
let series = SampleSeries::from_drained(drained, None);
let names = series.stats_path("").key_names();
assert!(names.contains(&"busy".to_string()));
assert!(names.contains(&"layers".to_string()));
}
#[test]
fn stats_path_projector_nested_key_drills_in() {
let drained = vec![(
"periodic_000".to_string(),
synthetic_report(0),
Some(synthetic_stats(50.0)),
Some(100),
)];
let series = SampleSeries::from_drained(drained, None);
let field = series.stats_path("layers").key("batch").field_f64("util");
let values: Vec<f64> = field
.values_iter()
.filter_map(|v| v.as_ref().ok().copied())
.collect();
assert_eq!(values.len(), 1);
assert!((values[0] - 25.0).abs() < f64::EPSILON);
}
#[test]
fn stats_path_projector_u64_fields_keeps_at_least_one_ok_excludes_all_err() {
let drained = vec![
(
"periodic_000".to_string(),
synthetic_report(0),
Some(mixed_stats(50, 7)),
Some(100),
),
(
"periodic_001".to_string(),
synthetic_report(0),
Some(mixed_stats(60, 9)),
Some(200),
),
];
let series = SampleSeries::from_drained(drained, None);
let fields = series.stats_path("").u64_fields();
let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"busy"),
"Number(integer) key must be kept: {names:?}",
);
assert!(
names.contains(&"count"),
"Number(integer) key must be kept: {names:?}",
);
assert!(
!names.contains(&"ratio"),
"Number(non-integer float) errors on every u64 projection — must be excluded: {names:?}",
);
assert!(
!names.contains(&"name"),
"String key must be excluded — every u64 projection errors: {names:?}",
);
}
#[test]
fn stats_path_projector_f64_fields_keeps_at_least_one_ok_excludes_all_err() {
let drained = vec![
(
"periodic_000".to_string(),
synthetic_report(0),
Some(mixed_stats(50, 7)),
Some(100),
),
(
"periodic_001".to_string(),
synthetic_report(0),
Some(mixed_stats(60, 9)),
Some(200),
),
];
let series = SampleSeries::from_drained(drained, None);
let fields = series.stats_path("").f64_fields();
let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"busy"),
"Number(integer) coerces to f64 — must be kept: {names:?}",
);
assert!(
names.contains(&"count"),
"Number(integer) coerces to f64 — must be kept: {names:?}",
);
assert!(
names.contains(&"ratio"),
"Number(non-integer float) coerces to f64 — must be kept: {names:?}",
);
assert!(
!names.contains(&"name"),
"String key must be excluded — every f64 projection errors: {names:?}",
);
}
#[test]
fn stats_path_projector_field_helpers_empty_series_yields_empty_vec() {
let series = SampleSeries::empty();
let u64s = series.stats_path("").u64_fields();
assert!(
u64s.is_empty(),
"empty series must yield empty u64_fields, got {} entries",
u64s.len(),
);
let f64s = series.stats_path("").f64_fields();
assert!(
f64s.is_empty(),
"empty series must yield empty f64_fields, got {} entries",
f64s.len(),
);
}
}