fix(stats): reject stale and discontinuous rolling counter baselines

This commit is contained in:
Nils Andreas Svee committed 2026-10-04 13:12:46 +07:00
1 parent 743a050416
commit a45396c236
5 files changed
+177 -34

No files matched your search

+83 -3
View File
@@ -121,7 +121,87 @@ fn rolling_windows_distinguish_upstream_and_downstream_loss() {
}
#[test]
fn split_probe_boundaries_keep_signed_estimates_and_require_a_baseline() {
fn rolling_baselines_cannot_include_unobserved_evicted_packets() {
let base = Instant::now();
let mut collector = StatsCollector::new(StatsConfig {
rolling_count: Some(2),
rolling_time: Some(Duration::from_millis(2)),
..StatsConfig::continuous()
});
for event in [
sent(base, 0),
reply(base, 0, 1),
sent(base, 1),
sent(base, 2),
sent(base, 3),
reply(base, 3, 4),
] {
collector.process(&event);
}
for rolling in [collector.rolling_count(), collector.rolling_time()] {
let rolling = rolling.unwrap();
assert_eq!(rolling.packets.packets_sent, 1);
assert_eq!(rolling.packets.packets_received, 1);
assert_eq!(rolling.packets.server_packets_received, Some(4));
assert_directional(&rolling, None, None);
}
assert_directional(&collector.snapshot(), Some(0), Some(2));
// The next pair has an observation at its boundary, so estimates recover.
collector.process(&sent(base, 4));
collector.process(&reply(base, 4, 5));
for rolling in [collector.rolling_count(), collector.rolling_time()] {
assert_directional(&rolling.unwrap(), Some(0), Some(0));
}
}
#[test]
fn rolling_counter_discontinuities_are_unavailable_until_a_fresh_interval() {
let base = Instant::now();
let mut collector = StatsCollector::new(StatsConfig {
rolling_count: Some(2),
rolling_time: Some(Duration::from_millis(2)),
..StatsConfig::continuous()
});
// Also cover an ambiguous high observation after wrap, followed by a return
// to post-wrap counts. Neither jump may be treated as a precise delta.
let counts = [u32::MAX - 1, u32::MAX, 0, 1, u32::MAX - 2, 2, 3];
for (seq, count) in counts.into_iter().enumerate() {
let seq = u32::try_from(seq).unwrap();
collector.process(&sent(base, seq));
collector.process(&reply(base, seq, count));
if seq == 0 {
continue;
}
for rolling in [collector.rolling_count(), collector.rolling_time()] {
let rolling = rolling.unwrap();
if matches!(seq, 1 | 3 | 6) {
assert_directional(&rolling, Some(0), Some(0));
} else {
assert_directional(&rolling, None, None);
assert_eq!(rolling.loss.upstream_loss_percent, 0.0);
assert_eq!(rolling.loss.downstream_loss_percent, 0.0);
assert_eq!(
rolling.packets.server_packets_received,
Some(u64::from(count))
);
}
}
}
let cumulative = collector.snapshot();
assert_eq!(
cumulative.packets.server_packets_received,
Some(u64::from(u32::MAX))
);
assert_directional(
&cumulative,
Some(7 - i128::from(u32::MAX)),
Some(i128::from(u32::MAX) - 7),
);
}
#[test]
fn split_probe_boundaries_require_a_current_server_baseline() {
let base = Instant::now();
let mut collector = StatsCollector::new(StatsConfig {
rolling_count: Some(1),
@@ -151,8 +231,8 @@ fn split_probe_boundaries_keep_signed_estimates_and_require_a_baseline() {
let rolling = rolling.unwrap();
assert_eq!(rolling.packets.packets_sent, 0);
assert_eq!(rolling.packets.packets_received, 1);
// The retained reply advances the count, but its send is outside the window.
assert_directional(&rolling, Some(-1), Some(0));
// The evicted send makes the earlier observation stale at this boundary.
assert_directional(&rolling, None, None);
}
assert_directional(&collector.snapshot(), Some(0), Some(0));
+32 -5
View File
@@ -263,18 +263,45 @@ fn rolling_reference(
if last - first + 1 != selected.len() {
return None;
}
// Partition the full observed history at ambiguous discontinuities.
// This independent oracle keeps observation lists for each segment
// rather than relying on the collector's retained metadata.
let mut observations = Vec::new();
let mut segment_start = 0;
for (index, (_, (event, _))) in packets[..=*last].iter().enumerate() {
if let Some(count) = count(event) {
if observations
.iter()
.max()
.is_some_and(|highest: &u64| highest.abs_diff(count) >= (1_u64 << 31))
{
segment_start = index + 1;
observations.clear();
}
observations.push(count);
}
}
if segment_start > *first {
return None;
}
let baseline = if *first == 0 {
Some(0)
} else {
packets[..*first]
// The immediately preceding packet must itself report the
// highest count. Earlier observations cannot bound this window.
let (_, (previous_event, _)) = packets[*first - 1];
let previous = count(previous_event)?;
if packets[segment_start.saturating_sub(1)..*first - 1]
.iter()
.filter_map(|(_, (event, _))| count(event))
.max()
.is_some_and(|highest| highest > previous)
{
return None;
}
Some(previous)
}?;
let endpoint = packets[..=*last]
.iter()
.filter_map(|(_, (event, _))| count(event))
.max()?;
let endpoint = observations.into_iter().max()?;
Some(endpoint - baseline)
});
snapshot.loss.upstream_loss_packets =