fix(stats): reject stale and discontinuous rolling counter baselines
This commit is contained in:
5 files changed
+177
-34
No files matched your search
@@ -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));
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user