use crate::assert::temporal::SeriesField;
use crate::monitor::dump::PerCpuTimeStats;
use super::{SampleRow, SampleSeries, build_series_field};
#[derive(Debug, Clone, Copy)]
#[must_use = "HostView is a borrowed view; call .per_cpu_time_timeline() / .per_cpu_field_u64() / .cpus() to project"]
#[non_exhaustive]
pub struct HostView<'a> {
rows: &'a [SampleRow],
}
impl<'a> HostView<'a> {
pub fn cpus(&self) -> Vec<u32> {
let mut seen = std::collections::BTreeSet::new();
for row in self.rows {
for entry in &row.report.per_cpu_time {
seen.insert(entry.cpu);
}
}
seen.into_iter().collect()
}
pub fn per_cpu_time_timeline(&self, cpu: u32) -> Vec<(u64, &'a PerCpuTimeStats)> {
let mut entries: Vec<(u64, &'a PerCpuTimeStats)> = Vec::new();
for row in self.rows {
if let Some(stats) = row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
entries.push((row.elapsed_ms, stats));
}
}
entries.sort_by_key(|(elapsed_ms, _)| *elapsed_ms);
entries
}
pub fn per_cpu_field_u64(
&self,
cpu: u32,
label: impl Into<String>,
project: impl Fn(&PerCpuTimeStats) -> u64,
) -> SeriesField<u64> {
build_series_field(self.rows, label, |row| {
if row.report.is_placeholder {
return Err(
crate::scenario::snapshot::SnapshotError::PlaceholderSample {
tag: row.tag.clone(),
reason: row
.report
.scx_walker_unavailable
.clone()
.unwrap_or_else(|| "placeholder report".to_string()),
},
);
}
match row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
Some(stats) => Ok(project(stats)),
None => Err(
crate::scenario::snapshot::SnapshotError::HostFieldUnavailable {
tag: row.tag.clone(),
cpu,
},
),
}
})
}
}
impl SampleSeries {
pub fn host(&self) -> Option<HostView<'_>> {
if self.rows.is_empty() {
None
} else {
Some(HostView { rows: &self.rows })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monitor::dump::FailureDumpReport;
#[test]
fn series_host_empty_series_returns_none() {
let series = SampleSeries::from_drained(vec![], None);
assert!(series.host().is_none());
}
#[test]
fn series_host_per_cpu_time_timeline_single_sample() {
let report = FailureDumpReport {
per_cpu_time: vec![
PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: 100,
..Default::default()
},
PerCpuTimeStats {
cpu: 3,
cpustat_user_ns: 300,
..Default::default()
},
],
..Default::default()
};
let series = SampleSeries::from_drained(
vec![("periodic_000".to_string(), report, None, Some(50u64))],
None,
);
let host = series.host().expect("non-empty series");
let t0 = host.per_cpu_time_timeline(0);
assert_eq!(t0.len(), 1);
assert_eq!(t0[0].0, 50);
assert_eq!(t0[0].1.cpustat_user_ns, 100);
let t3 = host.per_cpu_time_timeline(3);
assert_eq!(t3.len(), 1);
assert_eq!(t3[0].1.cpustat_user_ns, 300);
let t99 = host.per_cpu_time_timeline(99);
assert!(
t99.is_empty(),
"cpu not captured in any sample MUST yield empty timeline (not default-zero)"
);
assert_eq!(host.cpus(), vec![0, 3]);
}
#[test]
fn series_host_per_cpu_time_timeline_sorts_by_elapsed_ms_stable() {
let mk = |val: u64| FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: val,
..Default::default()
}],
..Default::default()
};
let series = SampleSeries::from_drained(
vec![
("a".to_string(), mk(100), None, Some(100u64)),
("b".to_string(), mk(200), None, Some(50u64)),
("c".to_string(), mk(300), None, Some(100u64)),
("d".to_string(), mk(400), None, Some(25u64)),
],
None,
);
let host = series.host().expect("non-empty");
let timeline = host.per_cpu_time_timeline(0);
assert_eq!(timeline.len(), 4);
assert_eq!(timeline[0].0, 25);
assert_eq!(timeline[0].1.cpustat_user_ns, 400);
assert_eq!(timeline[1].0, 50);
assert_eq!(timeline[1].1.cpustat_user_ns, 200);
assert_eq!(
timeline[2].0, 100,
"first of the tied-elapsed-ms pair: insertion order = 'a'"
);
assert_eq!(timeline[2].1.cpustat_user_ns, 100);
assert_eq!(
timeline[3].0, 100,
"second of the tied-elapsed-ms pair: insertion order = 'c'"
);
assert_eq!(timeline[3].1.cpustat_user_ns, 300);
}
#[test]
fn series_host_placeholder_naturally_drops_without_explicit_filter() {
let mk_real = |val: u64| FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: val,
..Default::default()
}],
..Default::default()
};
let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
let series = SampleSeries::from_drained(
vec![
("real_pre".to_string(), mk_real(10), None, Some(10u64)),
(
"placeholder_mid".to_string(),
placeholder,
None,
Some(20u64),
),
("real_post".to_string(), mk_real(30), None, Some(30u64)),
],
None,
);
let host = series.host().expect("non-empty");
let timeline = host.per_cpu_time_timeline(0);
assert_eq!(
timeline.len(),
2,
"placeholder MUST drop from the timeline naturally — pins the no-explicit-filter contract"
);
assert_eq!(timeline[0].0, 10);
assert_eq!(timeline[1].0, 30);
}
#[test]
fn series_host_per_cpu_field_u64_closure_projection() {
let mk = |val: u64| FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 1,
cpustat_system_ns: val,
..Default::default()
}],
..Default::default()
};
let mk_missing = || FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 0,
cpustat_system_ns: 999,
..Default::default()
}],
..Default::default()
};
let series = SampleSeries::from_drained(
vec![
("a".to_string(), mk(100), None, Some(10u64)),
("b".to_string(), mk_missing(), None, Some(20u64)),
("c".to_string(), mk(300), None, Some(30u64)),
],
None,
);
let host = series.host().expect("non-empty");
let field = host.per_cpu_field_u64(1, "system_ns_cpu1", |stats| stats.cpustat_system_ns);
let slots: Vec<_> = field.values_iter().collect();
assert_eq!(slots.len(), 3);
assert_eq!(*slots[0].as_ref().expect("cpu 1 captured in sample a"), 100);
match slots[1] {
Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
assert_eq!(tag, "b");
assert_eq!(*cpu, 1);
}
other => panic!(
"cpu 1 absent in sample b MUST surface as HostFieldUnavailable, got {other:?}"
),
}
assert_eq!(*slots[2].as_ref().expect("cpu 1 captured in sample c"), 300);
}
#[test]
fn series_host_per_cpu_field_u64_placeholder_surfaces_placeholder_sample_variant() {
let mk = |val: u64| FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: val,
..Default::default()
}],
..Default::default()
};
let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
let series = SampleSeries::from_drained(
vec![
("real".to_string(), mk(100), None, Some(10u64)),
("placeholder".to_string(), placeholder, None, Some(20u64)),
],
None,
);
let host = series.host().expect("non-empty");
let field = host.per_cpu_field_u64(0, "user_ns_cpu0", |s| s.cpustat_user_ns);
let slots: Vec<_> = field.values_iter().collect();
assert_eq!(slots.len(), 2);
assert_eq!(*slots[0].as_ref().expect("real sample Ok"), 100);
match slots[1] {
Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { tag, .. }) => {
assert_eq!(tag, "placeholder");
}
other => panic!(
"placeholder sample MUST surface as PlaceholderSample (not HostFieldUnavailable), got {other:?}"
),
}
}
#[test]
fn series_host_cpus_empty_when_all_samples_are_placeholders() {
let series = SampleSeries::from_drained(
vec![
(
"p0".to_string(),
FailureDumpReport::placeholder("t1"),
None,
Some(10u64),
),
(
"p1".to_string(),
FailureDumpReport::placeholder("t2"),
None,
Some(20u64),
),
(
"p2".to_string(),
FailureDumpReport::placeholder("t3"),
None,
Some(30u64),
),
],
None,
);
let host = series.host().expect("rows non-empty");
assert!(
host.cpus().is_empty(),
"all-placeholder series MUST surface cpus() as empty (no per_cpu_time data anywhere)"
);
}
#[test]
fn series_host_interleaved_multi_cpu_multi_sample_coverage() {
let mk = |cpus: &[(u32, u64)]| FailureDumpReport {
per_cpu_time: cpus
.iter()
.map(|(c, v)| PerCpuTimeStats {
cpu: *c,
cpustat_user_ns: *v,
..Default::default()
})
.collect(),
..Default::default()
};
let series = SampleSeries::from_drained(
vec![
("A".to_string(), mk(&[(0, 10), (1, 100)]), None, Some(10u64)),
(
"B".to_string(),
mk(&[(1, 200), (2, 300)]),
None,
Some(20u64),
),
("C".to_string(), mk(&[(0, 50), (2, 600)]), None, Some(30u64)),
],
None,
);
let host = series.host().expect("non-empty");
assert_eq!(host.cpus(), vec![0, 1, 2]);
let t0 = host.per_cpu_time_timeline(0);
assert_eq!(t0.len(), 2);
assert_eq!(t0[0].0, 10);
assert_eq!(t0[0].1.cpustat_user_ns, 10);
assert_eq!(t0[1].0, 30);
assert_eq!(t0[1].1.cpustat_user_ns, 50);
let t1 = host.per_cpu_time_timeline(1);
assert_eq!(t1.len(), 2);
assert_eq!(t1[0].1.cpustat_user_ns, 100);
assert_eq!(t1[1].1.cpustat_user_ns, 200);
let t2 = host.per_cpu_time_timeline(2);
assert_eq!(t2.len(), 2);
assert_eq!(t2[0].1.cpustat_user_ns, 300);
assert_eq!(t2[1].1.cpustat_user_ns, 600);
let field1 = host.per_cpu_field_u64(1, "cpu1_user", |s| s.cpustat_user_ns);
let slots: Vec<_> = field1.values_iter().collect();
assert_eq!(slots.len(), 3);
assert_eq!(*slots[0].as_ref().unwrap(), 100);
assert_eq!(*slots[1].as_ref().unwrap(), 200);
match slots[2] {
Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
assert_eq!(tag, "C");
assert_eq!(*cpu, 1);
}
other => panic!("expected HostFieldUnavailable for C/cpu=1, got {other:?}"),
}
}
#[test]
fn series_host_cpus_sorted_ascending_independent_of_insertion_order() {
let report = FailureDumpReport {
per_cpu_time: vec![
PerCpuTimeStats {
cpu: 5,
..Default::default()
},
PerCpuTimeStats {
cpu: 1,
..Default::default()
},
PerCpuTimeStats {
cpu: 3,
..Default::default()
},
],
..Default::default()
};
let series =
SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
let host = series.host().expect("non-empty");
assert_eq!(
host.cpus(),
vec![1, 3, 5],
"cpus() MUST return ascending-sorted distinct CPU ids regardless of per_cpu_time insertion order"
);
}
#[test]
fn series_host_per_cpu_time_timeline_first_match_wins_on_duplicate_cpu() {
let report = FailureDumpReport {
per_cpu_time: vec![
PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: 100,
..Default::default()
},
PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: 200,
..Default::default()
},
],
..Default::default()
};
let series =
SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
let host = series.host().expect("non-empty");
let timeline = host.per_cpu_time_timeline(0);
assert_eq!(timeline.len(), 1, "first-match-wins: timeline pushes once");
assert_eq!(
timeline[0].1.cpustat_user_ns, 100,
"first-match-wins: timeline returns FIRST entry (100), not second (200)"
);
let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
let slots: Vec<_> = field.values_iter().collect();
assert_eq!(slots.len(), 1);
assert_eq!(
*slots[0].as_ref().expect("Ok(first match value)"),
100,
"first-match-wins: per_cpu_field_u64 also returns FIRST entry"
);
}
#[test]
fn series_host_per_cpu_field_u64_iter_full_threads_tag_and_elapsed_correctly() {
let mk = |val: u64| FailureDumpReport {
per_cpu_time: vec![PerCpuTimeStats {
cpu: 0,
cpustat_user_ns: val,
..Default::default()
}],
..Default::default()
};
let series = SampleSeries::from_drained(
vec![
("alpha".to_string(), mk(10), None, Some(100u64)),
("beta".to_string(), mk(20), None, Some(200u64)),
("gamma".to_string(), mk(30), None, Some(300u64)),
],
None,
);
let host = series.host().expect("non-empty");
let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
let full: Vec<_> = field.iter_full().collect();
assert_eq!(full.len(), 3);
assert_eq!(full[0].0, "alpha");
assert_eq!(full[0].1, 100);
assert_eq!(*full[0].2.as_ref().unwrap(), 10);
assert_eq!(full[1].0, "beta");
assert_eq!(full[1].1, 200);
assert_eq!(*full[1].2.as_ref().unwrap(), 20);
assert_eq!(full[2].0, "gamma");
assert_eq!(full[2].1, 300);
assert_eq!(*full[2].2.as_ref().unwrap(), 30);
}
#[test]
fn series_host_per_cpu_field_u64_non_placeholder_empty_per_cpu_time_surfaces_host_field_unavailable()
{
let report = FailureDumpReport::default();
let series = SampleSeries::from_drained(
vec![("real_no_cpu_data".to_string(), report, None, Some(10u64))],
None,
);
let host = series.host().expect("non-empty");
let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
let slots: Vec<_> = field.values_iter().collect();
assert_eq!(slots.len(), 1);
match slots[0] {
Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
assert_eq!(tag, "real_no_cpu_data");
assert_eq!(*cpu, 0);
}
Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { .. }) => {
panic!(
"non-placeholder sample with empty per_cpu_time MUST surface as HostFieldUnavailable, NOT PlaceholderSample (regression: empty-per_cpu_time gating as placeholder)"
)
}
other => panic!("expected HostFieldUnavailable, got {other:?}"),
}
}
}