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

+26 -14
View File
@@ -39,25 +39,37 @@ Reading a snapshot does not advance time. Use count-based rolling storage for a
hard bound on retained events. The memory estimate excludes time-window storage. hard bound on retained events. The memory estimate excludes time-window storage.
Rolling directional loss uses the increase in the highest observed server Rolling directional loss uses the increase in the highest observed server
receive count across the retained packet events in arrival order. Upstream loss receive count within one observation segment across the retained packet events
is window sends minus that increase; downstream loss is the increase minus all in arrival order. Upstream loss is window sends minus that increase; downstream
window replies, including duplicates and untracked late replies, as in cumulative loss is the increase minus all window replies, including duplicates and untracked
accounting. The baseline is the highest count observed before the first retained late replies, as in cumulative accounting. The baseline must be supplied by the
packet event, or zero if no earlier packet events existed. Each retained event packet event immediately before
keeps this prior position, so eviction cannot erase a known baseline. Reordered the first retained packet event, or is zero if no earlier packet events existed.
counts do not move the observation backwards; only matched unique replies supply It must also equal the highest count observed in that segment. Intervening sends
counter observations, regardless of the late-reply timing policy. or replies without a current count make an older baseline stale: its next increase
may cover packets outside the window. Each retained event keeps the count's observation
ordinal, so eviction cannot erase this distinction. Reordered lower counts do not
refresh the baseline; only matched unique replies supply counter observations,
regardless of the late-reply timing policy.
Both directional packet estimates are `None` if the baseline or endpoint is Both directional packet estimates are `None` if the baseline or endpoint is
unknown, or if time filtering excludes a packet event between retained packet unknown or stale, or if time filtering excludes a packet event between retained
events. Their percentage fields use the existing unavailable value of `0.0`. packet events. Their percentage fields use the existing unavailable value of `0.0`.
Loss/warning events do not affect this interval. A boundary between a send and Loss/warning events do not affect this interval. Signed estimates remain possible
its reply can still produce signed estimates, and outstanding sends retain the when server counts exceed local expectations or extra replies arrive, and
existing provisional-loss interpretation. These are changes in observed outstanding sends retain the existing provisional-loss interpretation. These are
accounting, not exact network transit loss for probes sent in a wall-clock period. changes in observed accounting, not exact network transit loss for probes sent
in a wall-clock period.
Raw `server_packets_received`, ordinary packet counts, and cumulative loss retain Raw `server_packets_received`, ordinary packet counts, and cumulative loss retain
their existing meanings. their existing meanings.
A counter jump of at least half the 32-bit range is ambiguous between counter
wrap and a very old reordered reply from the other side of a wrap. It starts a
new observation segment; windows crossing the discontinuity have no directional
estimate. Later windows can recover using a fresh baseline within that segment.
The collector does not infer an epoch from incomplete arrival history. Smaller
regressions retain the existing maximum-count interpretation for reordered replies.
Exact snapshots sort a temporary copy of each metric's retained samples, adding Exact snapshots sort a temporary copy of each metric's retained samples, adding
O(n) scratch memory and O(n log n) computation for that metric. The retention O(n) scratch memory and O(n log n) computation for that metric. The retention
estimate is not peak snapshot or allocator accounting. estimate is not peak snapshot or allocator accounting.
+9 -4
View File
@@ -10,12 +10,17 @@ use crate::PacketCounts;
/// The directional fields are derived from /// The directional fields are derived from
/// [`PacketCounts::server_packets_received`] for cumulative snapshots. Rolling /// [`PacketCounts::server_packets_received`] for cumulative snapshots. Rolling
/// snapshots use the increase in the highest observed count over the retained /// snapshots use the increase in the highest observed count over the retained
/// packet-event arrival interval, with the preceding observation as baseline /// packet-event arrival interval within one observation segment. The baseline
/// must be that segment's highest count, reported by the preceding packet event
/// (zero at the start of the collector's history). They are `None` without a /// (zero at the start of the collector's history). They are `None` without a
/// known baseline/endpoint or when time filtering leaves an interior packet gap. /// current baseline or known endpoint, or when time filtering leaves an interior
/// packet gap. A jump of at least half the
/// 32-bit counter range starts a new observation segment: wrap and old replies
/// are ambiguous. Windows crossing that discontinuity have no directional loss;
/// later windows can use a fresh baseline within the new segment.
/// The percentage fields are `0.0` when the corresponding estimate is unavailable. /// The percentage fields are `0.0` when the corresponding estimate is unavailable.
/// These remain estimates: outstanding sends and replies crossing a window /// These remain estimates: outstanding sends, excess server counts, or extra
/// boundary can produce signed values. [`PacketCounts::server_received_window`] /// replies can produce signed values. [`PacketCounts::server_received_window`]
/// is never used to derive loss. /// is never used to derive loss.
pub struct LossStats { pub struct LossStats {
/// Locally inferred total lost packets. /// Locally inferred total lost packets.
+27 -8
View File
@@ -18,6 +18,8 @@ type ArrivalKey = (Instant, usize);
struct DirectionalPosition { struct DirectionalPosition {
packet_events: u128, packet_events: u128,
server_received: Option<u64>, server_received: Option<u64>,
server_observed_at: u128,
counter_reset_at: u128,
} }
impl DirectionalPosition { impl DirectionalPosition {
@@ -26,10 +28,21 @@ impl DirectionalPosition {
StatsEvent::UniqueReply { sample, .. } => { StatsEvent::UniqueReply { sample, .. } => {
if let Some(count) = sample.received_count { if let Some(count) = sample.received_count {
let count = u64::from(count); let count = u64::from(count);
self.server_received = Some( // A jump across half the 32-bit range can be a wrap or an
self.server_received // old reply from the other side of a wrap. Start a new
.map_or(count, |current| current.max(count)), // observation segment without guessing an epoch. Windows
); // spanning the discontinuity have no directional estimate.
if self
.server_received
.is_some_and(|current| current.abs_diff(count) >= (1_u64 << 31))
{
self.server_received = None;
self.counter_reset_at = self.packet_events + 1;
}
if self.server_received.is_none_or(|current| count >= current) {
self.server_received = Some(count);
self.server_observed_at = self.packet_events + 1;
}
} }
} }
StatsEvent::Sent { .. } StatsEvent::Sent { .. }
@@ -161,12 +174,18 @@ fn snapshot_window<'a>(
core.apply(event.event.clone()); core.apply(event.event.clone());
} }
let server_delta = start.zip(end).and_then(|(start, end)| { let server_delta = start.zip(end).and_then(|(start, end)| {
if !contiguous { if !contiguous || start.counter_reset_at != end.counter_reset_at {
return None; return None;
} }
let baseline = start let baseline = if start.packet_events == 0 {
.server_received 0
.or_else(|| (start.packet_events == 0).then_some(0))?; } else if start.server_observed_at == start.packet_events {
start.server_received?
} else {
// Packet events after the observation but outside the window can
// have advanced the server count by an unknown amount.
return None;
};
end.server_received.map(|count| count - baseline) end.server_received.map(|count| count - baseline)
}); });
let mut snapshot = core.snapshot(); let mut snapshot = core.snapshot();
+83 -3
View File
@@ -121,7 +121,87 @@ fn rolling_windows_distinguish_upstream_and_downstream_loss() {
} }
#[test] #[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 base = Instant::now();
let mut collector = StatsCollector::new(StatsConfig { let mut collector = StatsCollector::new(StatsConfig {
rolling_count: Some(1), rolling_count: Some(1),
@@ -151,8 +231,8 @@ fn split_probe_boundaries_keep_signed_estimates_and_require_a_baseline() {
let rolling = rolling.unwrap(); let rolling = rolling.unwrap();
assert_eq!(rolling.packets.packets_sent, 0); assert_eq!(rolling.packets.packets_sent, 0);
assert_eq!(rolling.packets.packets_received, 1); assert_eq!(rolling.packets.packets_received, 1);
// The retained reply advances the count, but its send is outside the window. // The evicted send makes the earlier observation stale at this boundary.
assert_directional(&rolling, Some(-1), Some(0)); assert_directional(&rolling, None, None);
} }
assert_directional(&collector.snapshot(), Some(0), Some(0)); 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() { if last - first + 1 != selected.len() {
return None; 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 { let baseline = if *first == 0 {
Some(0) Some(0)
} else { } 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() .iter()
.filter_map(|(_, (event, _))| count(event)) .filter_map(|(_, (event, _))| count(event))
.max() .max()
.is_some_and(|highest| highest > previous)
{
return None;
}
Some(previous)
}?; }?;
let endpoint = packets[..=*last] let endpoint = observations.into_iter().max()?;
.iter()
.filter_map(|(_, (event, _))| count(event))
.max()?;
Some(endpoint - baseline) Some(endpoint - baseline)
}); });
snapshot.loss.upstream_loss_packets = snapshot.loss.upstream_loss_packets =