mod common;
use std::collections::BTreeMap;
use std::path::Path;
use ktstr::ctprof::{CtprofSnapshot, ThreadState};
use ktstr::ctprof_compare::{
AggRule, Aggregated, CTPROF_METRICS, CompareOptions, CtprofCompareArgs, DisplayOptions,
GroupBy, aggregate, compare, run_compare, write_diff,
};
use ktstr::metric_types::{
Bytes, ClockTicks, CpuSet, GaugeCount, GaugeNs, MonotonicCount, MonotonicNs, OrdinalI32,
OrdinalU32, PeakBytes, PeakNs,
};
use common::ctprof::{cgroup_stats_entry, make_thread, snapshot};
fn compare_options(group_by: GroupBy, flatten: Vec<String>) -> CompareOptions {
let mut opts = CompareOptions::default();
opts.group_by = group_by.into();
opts.cgroup_flatten = flatten;
opts
}
#[test]
fn full_pipeline_with_disk_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let baseline_path = tmp.path().join("baseline.ctprof.zst");
let candidate_path = tmp.path().join("candidate.ctprof.zst");
let mut ta = make_thread("integration_proc", "worker");
ta.run_time_ns = MonotonicNs(1_000_000);
ta.voluntary_csw = MonotonicCount(10);
ta.nr_wakeups = MonotonicCount(100);
let mut tb = make_thread("integration_proc", "worker");
tb.run_time_ns = MonotonicNs(3_500_000);
tb.voluntary_csw = MonotonicCount(40);
tb.nr_wakeups = MonotonicCount(350);
snapshot(vec![ta], BTreeMap::new())
.write(&baseline_path)
.unwrap();
snapshot(vec![tb], BTreeMap::new())
.write(&candidate_path)
.unwrap();
let loaded_a = CtprofSnapshot::load(&baseline_path).unwrap();
let loaded_b = CtprofSnapshot::load(&candidate_path).unwrap();
assert_eq!(loaded_a.threads.len(), 1);
assert_eq!(loaded_b.threads.len(), 1);
let diff = compare(&loaded_a, &loaded_b, &CompareOptions::default());
assert!(diff.only_baseline.is_empty());
assert!(diff.only_candidate.is_empty());
let proc_rows: Vec<_> = diff
.rows
.iter()
.filter(|r| r.group_key == "integration_proc")
.collect();
assert_eq!(proc_rows.len(), CTPROF_METRICS.len());
let run_time = proc_rows
.iter()
.find(|r| r.metric_name == "run_time_ns")
.unwrap();
assert_eq!(run_time.delta, Some(2_500_000.0));
let csw = proc_rows
.iter()
.find(|r| r.metric_name == "voluntary_csw")
.unwrap();
assert_eq!(csw.delta, Some(30.0));
let wakeups = proc_rows
.iter()
.find(|r| r.metric_name == "nr_wakeups")
.unwrap();
assert_eq!(wakeups.delta, Some(250.0));
let mut out = String::new();
write_diff(
&mut out,
&diff,
&baseline_path,
&candidate_path,
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
for col in [
"pcomm",
"threads",
"metric",
"baseline",
"candidate",
"delta",
"%",
] {
assert!(out.contains(col), "missing column {col}:\n{out}");
}
assert!(
out.contains("integration_proc"),
"missing group key in output:\n{out}",
);
assert!(
out.contains("+2.500ms"),
"missing run_time_ns delta in output:\n{out}",
);
assert!(
out.contains("+250.0%"),
"missing run_time_ns pct in output:\n{out}",
);
}
#[test]
fn cgroup_flatten_joins_pods_with_different_ids() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.ctprof.zst");
let b_path = tmp.path().join("b.ctprof.zst");
let mut ta = make_thread("app", "worker");
ta.cgroup = "/kubepods/burstable/pod-AAA/container".into();
ta.run_time_ns = MonotonicNs(1_000);
let mut cgroup_stats_a = BTreeMap::new();
cgroup_stats_a.insert(
"/kubepods/burstable/pod-AAA/container".into(),
cgroup_stats_entry(100, 0, 0, 1 << 20),
);
let mut tb = make_thread("app", "worker");
tb.cgroup = "/kubepods/burstable/pod-BBB/container".into();
tb.run_time_ns = MonotonicNs(4_000);
let mut cgroup_stats_b = BTreeMap::new();
cgroup_stats_b.insert(
"/kubepods/burstable/pod-BBB/container".into(),
cgroup_stats_entry(400, 0, 0, 2 << 20),
);
snapshot(vec![ta], cgroup_stats_a).write(&a_path).unwrap();
snapshot(vec![tb], cgroup_stats_b).write(&b_path).unwrap();
let loaded_a = CtprofSnapshot::load(&a_path).unwrap();
let loaded_b = CtprofSnapshot::load(&b_path).unwrap();
let opts = compare_options(
GroupBy::Cgroup,
vec!["/kubepods/burstable/*/container".into()],
);
let diff = compare(&loaded_a, &loaded_b, &opts);
assert!(
diff.only_baseline.is_empty() && diff.only_candidate.is_empty(),
"flatten failed to group: only_a={:?} only_b={:?}",
diff.only_baseline,
diff.only_candidate,
);
let flat_key = "/kubepods/burstable/*/container";
assert!(
diff.rows.iter().any(|r| r.group_key == flat_key),
"missing flattened key {flat_key}",
);
let mut out = String::new();
write_diff(
&mut out,
&diff,
&a_path,
&b_path,
GroupBy::Cgroup,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("cpu_usage_usec"),
"missing enrichment header:\n{out}",
);
assert!(
out.contains("100µs → 400µs (+300µs)"),
"missing contiguous scaled triple `100µs → 400µs (+300µs)`:\n{out}",
);
}
#[test]
fn unmatched_groups_render_with_source_path() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.ctprof.zst");
let b_path = tmp.path().join("b.ctprof.zst");
snapshot(vec![make_thread("only_a", "w")], BTreeMap::new())
.write(&a_path)
.unwrap();
snapshot(vec![make_thread("only_b", "w")], BTreeMap::new())
.write(&b_path)
.unwrap();
let loaded_a = CtprofSnapshot::load(&a_path).unwrap();
let loaded_b = CtprofSnapshot::load(&b_path).unwrap();
let diff = compare(&loaded_a, &loaded_b, &CompareOptions::default());
assert_eq!(diff.only_baseline, vec!["only_a".to_string()]);
assert_eq!(diff.only_candidate, vec!["only_b".to_string()]);
let mut out = String::new();
write_diff(
&mut out,
&diff,
&a_path,
&b_path,
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(out.contains("only in baseline"));
assert!(out.contains("only in candidate"));
assert!(out.contains(Path::new(&a_path).file_name().unwrap().to_str().unwrap()));
assert!(out.contains(Path::new(&b_path).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn load_surfaces_error_on_malformed_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("bad.ctprof.zst");
std::fs::write(&path, b"not even zstd").unwrap();
let err = CtprofSnapshot::load(&path).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("ctprof") || msg.contains("zstd"),
"error context missing source hint:\n{msg}",
);
}
#[test]
fn run_compare_returns_ok_zero_regardless_of_diff_emptiness() {
let tmp = tempfile::tempdir().unwrap();
let baseline_path = tmp.path().join("baseline.ctprof.zst");
let candidate_path = tmp.path().join("candidate.ctprof.zst");
let mut ta = make_thread("run_compare_proc", "w");
ta.run_time_ns = MonotonicNs(10_000);
let mut tb = make_thread("run_compare_proc", "w");
tb.run_time_ns = MonotonicNs(50_000);
snapshot(vec![ta], BTreeMap::new())
.write(&baseline_path)
.unwrap();
snapshot(vec![tb], BTreeMap::new())
.write(&candidate_path)
.unwrap();
let args = CtprofCompareArgs {
baseline: baseline_path,
candidate: candidate_path,
group_by: GroupBy::Pcomm,
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: String::new(),
display_format: ktstr::ctprof_compare::DisplayFormat::Full,
columns: String::new(),
sections: String::new(),
metrics: String::new(),
wrap: false,
limit: 0,
};
let rc = run_compare(&args).expect("run_compare must succeed on valid snapshots");
assert_eq!(
rc, 0,
"run_compare must return Ok(0) on a non-empty diff \
(doc contract: 'a non-empty diff is data, not a failure'); \
got Ok({rc})",
);
}
#[test]
fn run_compare_with_valid_sort_by_succeeds() {
let tmp = tempfile::tempdir().unwrap();
let baseline_path = tmp.path().join("baseline.ctprof.zst");
let candidate_path = tmp.path().join("candidate.ctprof.zst");
let mut a1 = make_thread("alpha", "w");
a1.run_time_ns = MonotonicNs(1_000);
let mut a2 = make_thread("alpha", "w");
a2.run_time_ns = MonotonicNs(2_000);
let mut b1 = make_thread("bravo", "w");
b1.run_time_ns = MonotonicNs(100);
let mut b2 = make_thread("bravo", "w");
b2.run_time_ns = MonotonicNs(500);
snapshot(vec![a1, b1], BTreeMap::new())
.write(&baseline_path)
.unwrap();
snapshot(vec![a2, b2], BTreeMap::new())
.write(&candidate_path)
.unwrap();
let args = CtprofCompareArgs {
baseline: baseline_path,
candidate: candidate_path,
group_by: GroupBy::Pcomm,
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: "run_time_ns:DESC, wait_time_ns:asc".into(),
display_format: ktstr::ctprof_compare::DisplayFormat::Full,
columns: String::new(),
sections: String::new(),
metrics: String::new(),
wrap: false,
limit: 0,
};
let rc = run_compare(&args).expect("run_compare must accept a valid --sort-by spec end-to-end");
assert_eq!(rc, 0, "run_compare must return Ok(0) on success");
}
#[test]
fn run_compare_with_invalid_sort_by_returns_err() {
let tmp = tempfile::tempdir().unwrap();
let baseline_path = tmp.path().join("baseline.ctprof.zst");
let candidate_path = tmp.path().join("candidate.ctprof.zst");
let ta = make_thread("p", "w");
let tb = make_thread("p", "w");
snapshot(vec![ta], BTreeMap::new())
.write(&baseline_path)
.unwrap();
snapshot(vec![tb], BTreeMap::new())
.write(&candidate_path)
.unwrap();
let args = CtprofCompareArgs {
baseline: baseline_path,
candidate: candidate_path,
group_by: GroupBy::Pcomm,
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: "not_a_real_metric".into(),
display_format: ktstr::ctprof_compare::DisplayFormat::Full,
columns: String::new(),
sections: String::new(),
metrics: String::new(),
wrap: false,
limit: 0,
};
let err = run_compare(&args).expect_err("invalid --sort-by must produce Err");
let msg = format!("{err:#}");
assert!(
msg.contains("not_a_real_metric"),
"error must name the offending metric, got: {msg}",
);
assert!(
msg.contains("parse --sort-by"),
"error must carry the run_compare context preamble, got: {msg}",
);
}
#[test]
fn ctprof_metrics_accessors_read_every_variant() {
type MetricSetter = fn(&mut ThreadState);
let cases: &[(&str, MetricSetter)] = &[
("thread_count", |_| {}),
("policy", |t| t.policy = "SCHED_RR".into()),
("state", |t| t.state = 'R'),
("ext_enabled", |t| t.ext_enabled = true),
("nice", |t| t.nice = OrdinalI32(7)),
("processor", |t| t.processor = OrdinalI32(5)),
("cpu_affinity", |t| {
t.cpu_affinity = CpuSet(vec![10, 11, 12, 13, 14])
}),
("run_time_ns", |t| t.run_time_ns = MonotonicNs(100)),
("wait_time_ns", |t| t.wait_time_ns = MonotonicNs(101)),
("timeslices", |t| t.timeslices = MonotonicCount(102)),
("voluntary_csw", |t| t.voluntary_csw = MonotonicCount(103)),
("nonvoluntary_csw", |t| {
t.nonvoluntary_csw = MonotonicCount(104)
}),
("nr_wakeups", |t| t.nr_wakeups = MonotonicCount(105)),
("nr_wakeups_local", |t| {
t.nr_wakeups_local = MonotonicCount(106)
}),
("nr_wakeups_remote", |t| {
t.nr_wakeups_remote = MonotonicCount(107)
}),
("nr_wakeups_sync", |t| {
t.nr_wakeups_sync = MonotonicCount(108)
}),
("nr_wakeups_migrate", |t| {
t.nr_wakeups_migrate = MonotonicCount(109)
}),
("nr_wakeups_affine", |t| {
t.nr_wakeups_affine = MonotonicCount(111)
}),
("nr_wakeups_affine_attempts", |t| {
t.nr_wakeups_affine_attempts = MonotonicCount(112)
}),
("nr_migrations", |t| t.nr_migrations = MonotonicCount(113)),
("nr_forced_migrations", |t| {
t.nr_forced_migrations = MonotonicCount(115)
}),
("nr_failed_migrations_affine", |t| {
t.nr_failed_migrations_affine = MonotonicCount(116)
}),
("nr_failed_migrations_running", |t| {
t.nr_failed_migrations_running = MonotonicCount(117)
}),
("nr_failed_migrations_hot", |t| {
t.nr_failed_migrations_hot = MonotonicCount(118)
}),
("wait_sum", |t| t.wait_sum = MonotonicNs(119)),
("wait_count", |t| t.wait_count = MonotonicCount(120)),
("voluntary_sleep_ns", |t| {
t.voluntary_sleep_ns = MonotonicNs(121)
}),
("block_sum", |t| t.block_sum = MonotonicNs(122)),
("iowait_sum", |t| t.iowait_sum = MonotonicNs(123)),
("iowait_count", |t| t.iowait_count = MonotonicCount(124)),
("allocated_bytes", |t| t.allocated_bytes = Bytes(125)),
("deallocated_bytes", |t| t.deallocated_bytes = Bytes(126)),
("minflt", |t| t.minflt = MonotonicCount(127)),
("majflt", |t| t.majflt = MonotonicCount(128)),
("utime_clock_ticks", |t| {
t.utime_clock_ticks = ClockTicks(129)
}),
("stime_clock_ticks", |t| {
t.stime_clock_ticks = ClockTicks(130)
}),
("rchar", |t| t.rchar = Bytes(131)),
("wchar", |t| t.wchar = Bytes(132)),
("syscr", |t| t.syscr = MonotonicCount(133)),
("syscw", |t| t.syscw = MonotonicCount(134)),
("read_bytes", |t| t.read_bytes = Bytes(135)),
("write_bytes", |t| t.write_bytes = Bytes(136)),
("cancelled_write_bytes", |t| {
t.cancelled_write_bytes = Bytes(141)
}),
("wait_max", |t| t.wait_max = PeakNs(200)),
("sleep_max", |t| t.sleep_max = PeakNs(201)),
("block_max", |t| t.block_max = PeakNs(202)),
("exec_max", |t| t.exec_max = PeakNs(203)),
("slice_max", |t| t.slice_max = PeakNs(204)),
("priority", |t| t.priority = OrdinalI32(25)),
("rt_priority", |t| t.rt_priority = OrdinalU32(50)),
("core_forceidle_sum", |t| {
t.core_forceidle_sum = MonotonicNs(139)
}),
("fair_slice_ns", |t| t.fair_slice_ns = GaugeNs(250)),
("nr_threads", |t| t.nr_threads = GaugeCount(140)),
("cpu_delay_count", |t| {
t.cpu_delay_count = MonotonicCount(300)
}),
("cpu_delay_total_ns", |t| {
t.cpu_delay_total_ns = MonotonicNs(301)
}),
("cpu_delay_max_ns", |t| t.cpu_delay_max_ns = PeakNs(302)),
("cpu_delay_min_ns", |t| t.cpu_delay_min_ns = PeakNs(303)),
("blkio_delay_count", |t| {
t.blkio_delay_count = MonotonicCount(304)
}),
("blkio_delay_total_ns", |t| {
t.blkio_delay_total_ns = MonotonicNs(305)
}),
("blkio_delay_max_ns", |t| t.blkio_delay_max_ns = PeakNs(306)),
("blkio_delay_min_ns", |t| t.blkio_delay_min_ns = PeakNs(307)),
("swapin_delay_count", |t| {
t.swapin_delay_count = MonotonicCount(308)
}),
("swapin_delay_total_ns", |t| {
t.swapin_delay_total_ns = MonotonicNs(309)
}),
("swapin_delay_max_ns", |t| {
t.swapin_delay_max_ns = PeakNs(310)
}),
("swapin_delay_min_ns", |t| {
t.swapin_delay_min_ns = PeakNs(311)
}),
("freepages_delay_count", |t| {
t.freepages_delay_count = MonotonicCount(312)
}),
("freepages_delay_total_ns", |t| {
t.freepages_delay_total_ns = MonotonicNs(313)
}),
("freepages_delay_max_ns", |t| {
t.freepages_delay_max_ns = PeakNs(314)
}),
("freepages_delay_min_ns", |t| {
t.freepages_delay_min_ns = PeakNs(315)
}),
("thrashing_delay_count", |t| {
t.thrashing_delay_count = MonotonicCount(316)
}),
("thrashing_delay_total_ns", |t| {
t.thrashing_delay_total_ns = MonotonicNs(317)
}),
("thrashing_delay_max_ns", |t| {
t.thrashing_delay_max_ns = PeakNs(318)
}),
("thrashing_delay_min_ns", |t| {
t.thrashing_delay_min_ns = PeakNs(319)
}),
("compact_delay_count", |t| {
t.compact_delay_count = MonotonicCount(320)
}),
("compact_delay_total_ns", |t| {
t.compact_delay_total_ns = MonotonicNs(321)
}),
("compact_delay_max_ns", |t| {
t.compact_delay_max_ns = PeakNs(322)
}),
("compact_delay_min_ns", |t| {
t.compact_delay_min_ns = PeakNs(323)
}),
("wpcopy_delay_count", |t| {
t.wpcopy_delay_count = MonotonicCount(324)
}),
("wpcopy_delay_total_ns", |t| {
t.wpcopy_delay_total_ns = MonotonicNs(325)
}),
("wpcopy_delay_max_ns", |t| {
t.wpcopy_delay_max_ns = PeakNs(326)
}),
("wpcopy_delay_min_ns", |t| {
t.wpcopy_delay_min_ns = PeakNs(327)
}),
("irq_delay_count", |t| {
t.irq_delay_count = MonotonicCount(328)
}),
("irq_delay_total_ns", |t| {
t.irq_delay_total_ns = MonotonicNs(329)
}),
("irq_delay_max_ns", |t| t.irq_delay_max_ns = PeakNs(330)),
("irq_delay_min_ns", |t| t.irq_delay_min_ns = PeakNs(331)),
("hiwater_rss_bytes", |t| {
t.hiwater_rss_bytes = PeakBytes(332)
}),
("hiwater_vm_bytes", |t| t.hiwater_vm_bytes = PeakBytes(333)),
];
let expected_scalar: std::collections::BTreeMap<&str, u64> = [
("thread_count", 1),
("run_time_ns", 100),
("wait_time_ns", 101),
("timeslices", 102),
("voluntary_csw", 103),
("nonvoluntary_csw", 104),
("nr_wakeups", 105),
("nr_wakeups_local", 106),
("nr_wakeups_remote", 107),
("nr_wakeups_sync", 108),
("nr_wakeups_migrate", 109),
("nr_wakeups_affine", 111),
("nr_wakeups_affine_attempts", 112),
("nr_migrations", 113),
("nr_forced_migrations", 115),
("nr_failed_migrations_affine", 116),
("nr_failed_migrations_running", 117),
("nr_failed_migrations_hot", 118),
("wait_sum", 119),
("wait_count", 120),
("voluntary_sleep_ns", 121),
("block_sum", 122),
("iowait_sum", 123),
("iowait_count", 124),
("allocated_bytes", 125),
("deallocated_bytes", 126),
("minflt", 127),
("majflt", 128),
("utime_clock_ticks", 129),
("stime_clock_ticks", 130),
("rchar", 131),
("wchar", 132),
("syscr", 133),
("syscw", 134),
("read_bytes", 135),
("write_bytes", 136),
("cancelled_write_bytes", 141),
("core_forceidle_sum", 139),
("wait_max", 200),
("sleep_max", 201),
("block_max", 202),
("exec_max", 203),
("slice_max", 204),
("fair_slice_ns", 250),
("nr_threads", 140),
("cpu_delay_count", 300),
("cpu_delay_total_ns", 301),
("cpu_delay_max_ns", 302),
("cpu_delay_min_ns", 303),
("blkio_delay_count", 304),
("blkio_delay_total_ns", 305),
("blkio_delay_max_ns", 306),
("blkio_delay_min_ns", 307),
("swapin_delay_count", 308),
("swapin_delay_total_ns", 309),
("swapin_delay_max_ns", 310),
("swapin_delay_min_ns", 311),
("freepages_delay_count", 312),
("freepages_delay_total_ns", 313),
("freepages_delay_max_ns", 314),
("freepages_delay_min_ns", 315),
("thrashing_delay_count", 316),
("thrashing_delay_total_ns", 317),
("thrashing_delay_max_ns", 318),
("thrashing_delay_min_ns", 319),
("compact_delay_count", 320),
("compact_delay_total_ns", 321),
("compact_delay_max_ns", 322),
("compact_delay_min_ns", 323),
("wpcopy_delay_count", 324),
("wpcopy_delay_total_ns", 325),
("wpcopy_delay_max_ns", 326),
("wpcopy_delay_min_ns", 327),
("irq_delay_count", 328),
("irq_delay_total_ns", 329),
("irq_delay_max_ns", 330),
("irq_delay_min_ns", 331),
("hiwater_rss_bytes", 332),
("hiwater_vm_bytes", 333),
]
.into_iter()
.collect();
for (name, set) in cases {
let mut t = make_thread("p", "w");
set(&mut t);
let def = CTPROF_METRICS
.iter()
.find(|m| m.name == *name)
.unwrap_or_else(|| panic!("metric {name} not in registry"));
let agg = aggregate(def.rule, &[&t]);
match (def.rule, &agg) {
(
AggRule::SumCount(_)
| AggRule::SumNs(_)
| AggRule::SumTicks(_)
| AggRule::SumBytes(_),
Aggregated::Sum(v),
) => {
let expected = *expected_scalar.get(name).unwrap_or_else(|| {
panic!("Sum metric {name} missing from expected_scalar table")
});
assert_eq!(
*v, expected,
"Sum accessor for {name} read the wrong field — \
got {v}, want {expected}",
);
}
(
AggRule::MaxPeak(_)
| AggRule::MaxPeakBytes(_)
| AggRule::MaxGaugeNs(_)
| AggRule::MaxGaugeCount(_),
Aggregated::Max(v),
) => {
let expected = *expected_scalar.get(name).unwrap_or_else(|| {
panic!("Max metric {name} missing from expected_scalar table")
});
assert_eq!(
*v, expected,
"Max accessor for {name} read the wrong field — \
got {v}, want {expected}",
);
}
(
AggRule::RangeI32(_) | AggRule::RangeU32(_),
Aggregated::OrdinalRange { min, max },
) => {
let expected: i64 = match *name {
"nice" => 7,
"processor" => 5,
"priority" => 25,
"rt_priority" => 50,
_ => panic!("unexpected OrdinalRange metric {name}"),
};
assert_eq!(
*min, expected,
"OrdinalRange min for {name} did not read the populated value",
);
assert_eq!(
*max, expected,
"OrdinalRange max for {name} did not read the populated value",
);
}
(
AggRule::Mode(_) | AggRule::ModeChar(_) | AggRule::ModeBool(_),
Aggregated::Mode { total, .. },
) => {
let expected: &str = match *name {
"policy" => "SCHED_RR",
"state" => "R",
"ext_enabled" => "true",
_ => panic!("unexpected Mode metric {name}"),
};
assert_eq!(
agg.mode_value(),
expected,
"Mode accessor for {name} did not read the populated value",
);
assert_eq!(
agg.mode_count(),
1,
"Mode count for single-thread aggregate must be 1"
);
assert_eq!(
*total, 1,
"Mode total for single-thread aggregate must be 1"
);
}
(AggRule::Affinity(_), Aggregated::Affinity(s)) => {
assert_eq!(
s.min_cpus, 5,
"Affinity min_cpus did not match populated cpuset len (5)",
);
assert_eq!(
s.max_cpus, 5,
"Affinity max_cpus did not match populated cpuset len (5)",
);
let cpus = s
.uniform
.as_ref()
.expect("single-thread Affinity must be uniform");
assert_eq!(cpus, &vec![10, 11, 12, 13, 14]);
}
(rule, agg) => {
panic!("rule/aggregate variant mismatch for {name}: rule={rule:?}, agg={agg:?}",)
}
}
}
let case_names: std::collections::BTreeSet<&str> =
cases.iter().map(|(name, _)| *name).collect();
let registry_names: std::collections::BTreeSet<&str> =
CTPROF_METRICS.iter().map(|m| m.name).collect();
assert_eq!(
case_names.len(),
cases.len(),
"duplicate metric in test case table — set-vs-vec length mismatch",
);
assert_eq!(
case_names, registry_names,
"test cases must mirror CTPROF_METRICS exactly; \
missing-from-cases or extra-in-cases delta surfaces here",
);
}
#[test]
fn nr_threads_leader_dedup_aggregates_via_max_on_leader_value() {
let mut leader = make_thread("server", "server-leader");
leader.tid = 4242;
leader.tgid = 4242;
leader.nr_threads = GaugeCount(3);
let mut follower_a = make_thread("server", "server-worker-1");
follower_a.tid = 4243;
follower_a.tgid = 4242;
follower_a.nr_threads = GaugeCount(0);
let mut follower_b = make_thread("server", "server-worker-2");
follower_b.tid = 4244;
follower_b.tgid = 4242;
follower_b.nr_threads = GaugeCount(0);
let def = CTPROF_METRICS
.iter()
.find(|m| m.name == "nr_threads")
.expect("nr_threads metric must be in CTPROF_METRICS");
assert!(
matches!(def.rule, AggRule::MaxGaugeCount(_)),
"nr_threads must be registered as AggRule::MaxGaugeCount \
— any Sum* variant is wrong because non-leader threads \
contribute 0 (capture-side leader-dedup), so a \
comm/cgroup bucket whose leader lives elsewhere would \
render 0 under Sum; MaxPeak/MaxGaugeNs is wrong because \
the wrapper type would not match GaugeCount",
);
let agg = aggregate(def.rule, &[&leader, &follower_a, &follower_b]);
match agg {
Aggregated::Max(v) => {
assert_eq!(
v, 3,
"Max across [leader=3, follower=0, follower=0] must \
read the leader's value (3); a result of 0 means the \
followers' zeros displaced the leader OR the accessor \
read the wrong field",
);
}
other => panic!(
"nr_threads aggregator returned wrong variant — \
expected Aggregated::Max(3), got {other:?}. A Sum-\
shaped aggregate would silently produce Sum(3) here, \
which is the regression this test guards against.",
),
}
}
#[test]
fn compare_smaps_rollup_renders_header_and_scaled_byte_values() {
use ktstr::ctprof_compare::{CompareOptions, DisplayOptions, GroupBy, compare, write_diff};
use std::path::Path;
let mut baseline_leader = make_thread("worker", "worker");
baseline_leader.tid = 4242;
baseline_leader.tgid = 4242;
baseline_leader.smaps_rollup_kb.insert("Rss".into(), 4096);
baseline_leader.smaps_rollup_kb.insert("Pss".into(), 1024);
let mut candidate_leader = make_thread("worker", "worker");
candidate_leader.tid = 4242;
candidate_leader.tgid = 4242;
candidate_leader.smaps_rollup_kb.insert("Rss".into(), 4096);
candidate_leader.smaps_rollup_kb.insert("Pss".into(), 2048);
let baseline = snapshot(vec![baseline_leader], BTreeMap::new());
let candidate = snapshot(vec![candidate_leader], BTreeMap::new());
let opts = CompareOptions::default();
let diff = compare(&baseline, &candidate, &opts);
assert_eq!(
diff.smaps_rollup_a
.get("worker")
.and_then(|m| m.get("Pss").copied()),
Some(1024 * 1024),
"baseline Pss kB-to-bytes conversion landed in diff",
);
assert_eq!(
diff.smaps_rollup_b
.get("worker")
.and_then(|m| m.get("Pss").copied()),
Some(2048 * 1024),
"candidate Pss kB-to-bytes conversion landed in diff",
);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("## smaps_rollup"),
"smaps_rollup section header missing:\n{out}",
);
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps_rollup header located above");
let after_smaps = &out[smaps_at..];
let next_section_in_smaps = after_smaps
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(after_smaps.len());
let smaps_section = &after_smaps[..next_section_in_smaps];
assert!(
smaps_section.contains("worker"),
"rendered smaps section missing process key `worker`:\n{smaps_section}",
);
assert!(
out.contains("Pss"),
"Pss row (changed) must appear in rendered table:\n{out}",
);
assert!(
out.contains("1.000MiB") && out.contains("2.000MiB"),
"expected baseline 1 MiB and candidate 2 MiB cells:\n{out}",
);
let header_pos = out.find("## smaps_rollup").unwrap();
let after = &out[header_pos..];
let next_section = after.find("\n## ").map(|p| p + 1).unwrap_or(after.len());
let section = &after[..next_section];
assert!(
!section.contains("Rss"),
"unchanged key (Rss: 4096 kB on both) must be suppressed in section:\n{section}",
);
}
#[test]
fn compare_smaps_rollup_suppresses_section_when_all_unchanged() {
use ktstr::ctprof_compare::{CompareOptions, DisplayOptions, GroupBy, compare, write_diff};
use std::path::Path;
let mut leader = make_thread("worker", "worker");
leader.tid = 4242;
leader.tgid = 4242;
leader.smaps_rollup_kb.insert("Rss".into(), 4096);
leader.smaps_rollup_kb.insert("Pss".into(), 1024);
let baseline = snapshot(vec![leader.clone()], BTreeMap::new());
let candidate = snapshot(vec![leader], BTreeMap::new());
let opts = CompareOptions::default();
let diff = compare(&baseline, &candidate, &opts);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
!out.contains("## smaps_rollup"),
"section header must be suppressed when no key changed:\n{out}",
);
}
#[test]
fn compare_sched_ext_renders_section_with_state_and_counter_deltas() {
use ktstr::ctprof::SchedExtSysfs;
use ktstr::ctprof_compare::{CompareOptions, DisplayOptions, GroupBy, compare, write_diff};
use std::path::Path;
let baseline_scx = {
let mut s = SchedExtSysfs::default();
s.state = "disabled".into();
s.switch_all = 0;
s.nr_rejected = 0;
s.hotplug_seq = 100;
s.enable_seq = 5;
s
};
let candidate_scx = {
let mut s = SchedExtSysfs::default();
s.state = "enabled".into();
s.switch_all = 1;
s.nr_rejected = 7;
s.hotplug_seq = 100;
s.enable_seq = 7;
s
};
let mut baseline = snapshot(vec![], BTreeMap::new());
baseline.sched_ext = Some(baseline_scx);
let mut candidate = snapshot(vec![], BTreeMap::new());
candidate.sched_ext = Some(candidate_scx);
let opts = CompareOptions::default();
let diff = compare(&baseline, &candidate, &opts);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("## sched_ext"),
"sched_ext section header missing:\n{out}",
);
assert!(
out.contains("disabled \u{2192} enabled"),
"state cell must render `disabled → enabled`:\n{out}",
);
assert!(
out.contains("0 \u{2192} 1 (+1)"),
"switch_all counter delta missing:\n{out}",
);
assert!(
out.contains("0 \u{2192} 7 (+7)"),
"nr_rejected counter delta missing:\n{out}",
);
assert!(
out.contains("100 \u{2192} 100 (+0)"),
"hotplug_seq unchanged cell missing:\n{out}",
);
assert!(
out.contains("5 \u{2192} 7 (+2)"),
"enable_seq counter delta missing:\n{out}",
);
}
#[test]
fn compare_sched_ext_suppresses_section_when_both_sides_none() {
use ktstr::ctprof_compare::{CompareOptions, DisplayOptions, GroupBy, compare, write_diff};
use std::path::Path;
let baseline = snapshot(vec![], BTreeMap::new());
let candidate = snapshot(vec![], BTreeMap::new());
let opts = CompareOptions::default();
let diff = compare(&baseline, &candidate, &opts);
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
!out.contains("## sched_ext"),
"section must be suppressed when both sides have no sched_ext:\n{out}",
);
}
#[test]
fn taskstats_fields_serde_roundtrip_preserves_all_34_fields() {
use ktstr::metric_types::{MonotonicCount, MonotonicNs, PeakBytes, PeakNs};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("taskstats.ctprof.zst");
let mut t = make_thread("ts_proc", "ts_worker");
t.cpu_delay_count = MonotonicCount(300);
t.cpu_delay_total_ns = MonotonicNs(301);
t.cpu_delay_max_ns = PeakNs(302);
t.cpu_delay_min_ns = PeakNs(303);
t.blkio_delay_count = MonotonicCount(304);
t.blkio_delay_total_ns = MonotonicNs(305);
t.blkio_delay_max_ns = PeakNs(306);
t.blkio_delay_min_ns = PeakNs(307);
t.swapin_delay_count = MonotonicCount(308);
t.swapin_delay_total_ns = MonotonicNs(309);
t.swapin_delay_max_ns = PeakNs(310);
t.swapin_delay_min_ns = PeakNs(311);
t.freepages_delay_count = MonotonicCount(312);
t.freepages_delay_total_ns = MonotonicNs(313);
t.freepages_delay_max_ns = PeakNs(314);
t.freepages_delay_min_ns = PeakNs(315);
t.thrashing_delay_count = MonotonicCount(316);
t.thrashing_delay_total_ns = MonotonicNs(317);
t.thrashing_delay_max_ns = PeakNs(318);
t.thrashing_delay_min_ns = PeakNs(319);
t.compact_delay_count = MonotonicCount(320);
t.compact_delay_total_ns = MonotonicNs(321);
t.compact_delay_max_ns = PeakNs(322);
t.compact_delay_min_ns = PeakNs(323);
t.wpcopy_delay_count = MonotonicCount(324);
t.wpcopy_delay_total_ns = MonotonicNs(325);
t.wpcopy_delay_max_ns = PeakNs(326);
t.wpcopy_delay_min_ns = PeakNs(327);
t.irq_delay_count = MonotonicCount(328);
t.irq_delay_total_ns = MonotonicNs(329);
t.irq_delay_max_ns = PeakNs(330);
t.irq_delay_min_ns = PeakNs(331);
t.hiwater_rss_bytes = PeakBytes(332);
t.hiwater_vm_bytes = PeakBytes(333);
snapshot(vec![t.clone()], BTreeMap::new())
.write(&path)
.unwrap();
let loaded = CtprofSnapshot::load(&path).unwrap();
assert_eq!(loaded.threads.len(), 1);
let r = &loaded.threads[0];
assert_eq!(r.cpu_delay_count, t.cpu_delay_count, "cpu_delay_count");
assert_eq!(
r.cpu_delay_total_ns, t.cpu_delay_total_ns,
"cpu_delay_total_ns"
);
assert_eq!(r.cpu_delay_max_ns, t.cpu_delay_max_ns, "cpu_delay_max_ns");
assert_eq!(r.cpu_delay_min_ns, t.cpu_delay_min_ns, "cpu_delay_min_ns");
assert_eq!(
r.blkio_delay_count, t.blkio_delay_count,
"blkio_delay_count"
);
assert_eq!(
r.blkio_delay_total_ns, t.blkio_delay_total_ns,
"blkio_delay_total_ns"
);
assert_eq!(
r.blkio_delay_max_ns, t.blkio_delay_max_ns,
"blkio_delay_max_ns"
);
assert_eq!(
r.blkio_delay_min_ns, t.blkio_delay_min_ns,
"blkio_delay_min_ns"
);
assert_eq!(
r.swapin_delay_count, t.swapin_delay_count,
"swapin_delay_count"
);
assert_eq!(
r.swapin_delay_total_ns, t.swapin_delay_total_ns,
"swapin_delay_total_ns"
);
assert_eq!(
r.swapin_delay_max_ns, t.swapin_delay_max_ns,
"swapin_delay_max_ns"
);
assert_eq!(
r.swapin_delay_min_ns, t.swapin_delay_min_ns,
"swapin_delay_min_ns"
);
assert_eq!(
r.freepages_delay_count, t.freepages_delay_count,
"freepages_delay_count"
);
assert_eq!(
r.freepages_delay_total_ns, t.freepages_delay_total_ns,
"freepages_delay_total_ns"
);
assert_eq!(
r.freepages_delay_max_ns, t.freepages_delay_max_ns,
"freepages_delay_max_ns"
);
assert_eq!(
r.freepages_delay_min_ns, t.freepages_delay_min_ns,
"freepages_delay_min_ns"
);
assert_eq!(
r.thrashing_delay_count, t.thrashing_delay_count,
"thrashing_delay_count"
);
assert_eq!(
r.thrashing_delay_total_ns, t.thrashing_delay_total_ns,
"thrashing_delay_total_ns"
);
assert_eq!(
r.thrashing_delay_max_ns, t.thrashing_delay_max_ns,
"thrashing_delay_max_ns"
);
assert_eq!(
r.thrashing_delay_min_ns, t.thrashing_delay_min_ns,
"thrashing_delay_min_ns"
);
assert_eq!(
r.compact_delay_count, t.compact_delay_count,
"compact_delay_count"
);
assert_eq!(
r.compact_delay_total_ns, t.compact_delay_total_ns,
"compact_delay_total_ns"
);
assert_eq!(
r.compact_delay_max_ns, t.compact_delay_max_ns,
"compact_delay_max_ns"
);
assert_eq!(
r.compact_delay_min_ns, t.compact_delay_min_ns,
"compact_delay_min_ns"
);
assert_eq!(
r.wpcopy_delay_count, t.wpcopy_delay_count,
"wpcopy_delay_count"
);
assert_eq!(
r.wpcopy_delay_total_ns, t.wpcopy_delay_total_ns,
"wpcopy_delay_total_ns"
);
assert_eq!(
r.wpcopy_delay_max_ns, t.wpcopy_delay_max_ns,
"wpcopy_delay_max_ns"
);
assert_eq!(
r.wpcopy_delay_min_ns, t.wpcopy_delay_min_ns,
"wpcopy_delay_min_ns"
);
assert_eq!(r.irq_delay_count, t.irq_delay_count, "irq_delay_count");
assert_eq!(
r.irq_delay_total_ns, t.irq_delay_total_ns,
"irq_delay_total_ns"
);
assert_eq!(r.irq_delay_max_ns, t.irq_delay_max_ns, "irq_delay_max_ns");
assert_eq!(r.irq_delay_min_ns, t.irq_delay_min_ns, "irq_delay_min_ns");
assert_eq!(
r.hiwater_rss_bytes, t.hiwater_rss_bytes,
"hiwater_rss_bytes"
);
assert_eq!(r.hiwater_vm_bytes, t.hiwater_vm_bytes, "hiwater_vm_bytes");
}
#[test]
fn taskstats_metrics_render_with_auto_scaled_ns_and_bytes_ladders() {
use ktstr::ctprof_compare::{CompareOptions, DisplayOptions, GroupBy, compare, write_diff};
use ktstr::metric_types::{MonotonicNs, PeakBytes};
use std::path::Path;
let mut ta = make_thread("worker", "worker");
ta.cpu_delay_total_ns = MonotonicNs(5_000_000); ta.hiwater_rss_bytes = PeakBytes(1024 * 1024); let mut tb = make_thread("worker", "worker");
tb.cpu_delay_total_ns = MonotonicNs(12_500_000); tb.hiwater_rss_bytes = PeakBytes(2 * 1024 * 1024);
let baseline = snapshot(vec![ta], BTreeMap::new());
let candidate = snapshot(vec![tb], BTreeMap::new());
let diff = compare(&baseline, &candidate, &CompareOptions::default());
let mut out = String::new();
write_diff(
&mut out,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&DisplayOptions::default(),
)
.unwrap();
assert!(
out.contains("cpu_delay_total_ns"),
"cpu_delay_total_ns row missing from rendered table:\n{out}",
);
assert!(
out.contains("hiwater_rss_bytes"),
"hiwater_rss_bytes row missing from rendered table:\n{out}",
);
assert!(
out.contains("5.000ms"),
"cpu_delay_total_ns baseline cell `5.000ms` missing:\n{out}",
);
assert!(
out.contains("12.500ms"),
"cpu_delay_total_ns candidate cell `12.500ms` missing:\n{out}",
);
assert!(
out.contains("+7.500ms"),
"cpu_delay_total_ns delta cell `+7.500ms` missing:\n{out}",
);
assert!(
out.contains("1.000MiB"),
"hiwater_rss_bytes baseline cell `1.000MiB` missing:\n{out}",
);
assert!(
out.contains("2.000MiB"),
"hiwater_rss_bytes candidate cell `2.000MiB` missing:\n{out}",
);
assert!(
out.contains("+1.000MiB"),
"hiwater_rss_bytes delta cell `+1.000MiB` missing:\n{out}",
);
}
#[test]
fn sections_filter_isolates_taskstats_delay_rows_in_primary_table() {
use ktstr::ctprof_compare::{
CompareOptions, DisplayOptions, GroupBy, Section, compare, write_diff,
};
use ktstr::metric_types::{MonotonicCount, MonotonicNs};
use std::path::Path;
let mut ta = make_thread("worker", "worker");
ta.cpu_delay_total_ns = MonotonicNs(5_000_000);
ta.run_time_ns = MonotonicNs(1_000_000);
ta.voluntary_csw = MonotonicCount(10);
let mut tb = make_thread("worker", "worker");
tb.cpu_delay_total_ns = MonotonicNs(12_500_000);
tb.run_time_ns = MonotonicNs(3_500_000);
tb.voluntary_csw = MonotonicCount(40);
let baseline = snapshot(vec![ta], BTreeMap::new());
let candidate = snapshot(vec![tb], BTreeMap::new());
let diff = compare(&baseline, &candidate, &CompareOptions::default());
let mut display = DisplayOptions::default();
display.sections = vec![Section::TaskstatsDelay];
let mut out_taskstats = String::new();
write_diff(
&mut out_taskstats,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out_taskstats.contains("cpu_delay_total_ns"),
"taskstats-delay filter must keep cpu_delay_total_ns row:\n{out_taskstats}",
);
assert!(
!out_taskstats.contains("run_time_ns"),
"taskstats-delay filter must EXCLUDE run_time_ns row (Section::Primary):\n{out_taskstats}",
);
assert!(
!out_taskstats.contains("voluntary_csw"),
"taskstats-delay filter must EXCLUDE voluntary_csw row (Section::Primary):\n{out_taskstats}",
);
let mut display = DisplayOptions::default();
display.sections = vec![Section::Primary];
let mut out_primary = String::new();
write_diff(
&mut out_primary,
&diff,
Path::new("a"),
Path::new("b"),
GroupBy::Pcomm,
&display,
)
.unwrap();
assert!(
out_primary.contains("run_time_ns"),
"primary filter must keep run_time_ns row:\n{out_primary}",
);
assert!(
!out_primary.contains("cpu_delay_total_ns"),
"primary filter must EXCLUDE cpu_delay_total_ns (Section::TaskstatsDelay):\n{out_primary}",
);
assert!(
!out_primary.contains("hiwater_rss_bytes"),
"primary filter must EXCLUDE hiwater_rss_bytes (Section::TaskstatsDelay):\n{out_primary}",
);
}