fix(stats): align rolling directional loss with window counts

This commit is contained in:
Nils Andreas Svee committed 2026-10-04 12:20:47 +07:00
1 parent 524d56f72c
commit 743a050416
8 files changed
+489 -34

No files matched your search

+1 -1
View File
@@ -234,7 +234,7 @@ impl CoreStats {
Snapshot {
events: self.events,
packets,
loss: loss_stats(packets),
loss: loss_stats(packets, packets.server_packets_received),
send_call: self.send_call.snapshot(),
timer_error: self.timer_error.snapshot(),
rtt: self.rtt_stats(),
+3 -2
View File
@@ -599,8 +599,9 @@ pub struct PacketCounts {
pub bytes_received: u64,
/// Highest cumulative server-reported packets received, when available.
///
/// This is the only server-reported input to the directional loss estimates
/// in [`LossStats`].
/// Rolling snapshots also preserve this raw cumulative value from retained
/// replies. Their directional loss uses an interval-relative increase
/// instead; see [`LossStats`] for baseline and availability semantics.
pub server_packets_received: Option<u64>,
/// Raw server-reported receive window from the most recently processed
/// reply that carried one, when available.
+11 -5
View File
@@ -8,9 +8,15 @@ use crate::PacketCounts;
/// server-reported counts exceed local expectations.
///
/// The directional fields are derived from
/// [`PacketCounts::server_packets_received`] and are `None` when the server
/// reported no receive count. [`PacketCounts::server_received_window`] is never
/// used to derive loss.
/// [`PacketCounts::server_packets_received`] for cumulative snapshots. Rolling
/// snapshots use the increase in the highest observed count over the retained
/// packet-event arrival interval, with the preceding observation as baseline
/// (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.
/// The percentage fields are `0.0` when the corresponding estimate is unavailable.
/// These remain estimates: outstanding sends and replies crossing a window
/// boundary can produce signed values. [`PacketCounts::server_received_window`]
/// is never used to derive loss.
pub struct LossStats {
/// Locally inferred total lost packets.
pub lost_packets: u64,
@@ -30,7 +36,7 @@ pub struct LossStats {
pub late_packets_percent: f64,
}
pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
pub(crate) fn loss_stats(packets: PacketCounts, server_received: Option<u64>) -> LossStats {
let lost = packets.packets_sent.saturating_sub(packets.unique_replies);
let packet_loss_percent = if packets.packets_sent == 0 {
0.0
@@ -45,7 +51,7 @@ pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
upstream_loss_percent,
downstream_loss_packets,
downstream_loss_percent,
) = if let Some(server_received) = packets.server_packets_received {
) = if let Some(server_received) = server_received {
let upstream = i128::from(packets.packets_sent) - i128::from(server_received);
let downstream = i128::from(server_received) - i128::from(packets.packets_received);
let upstream_percent = if packets.packets_sent == 0 {
+5 -4
View File
@@ -9,7 +9,8 @@ use std::mem::size_of;
use crate::core::CONTINUOUS_SEQUENCE_LIMIT;
use crate::ipdv::IpdvSample;
use crate::normalization::{ReplySample, StatsEvent};
use crate::normalization::ReplySample;
use crate::rolling::RollingEvent;
use crate::{SampleMode, StatsConfig};
/// Capacity headroom applied to the live element bytes.
@@ -94,8 +95,8 @@ fn rolling_count_bytes(config: &StatsConfig, probe_count: u64) -> u64 {
retained.saturating_mul(rolling_bytes_per_event())
}
/// A retained event is the enum itself plus, for a unique reply, the boxed
/// reply sample it owns.
/// A retained event includes its directional baseline plus, for a unique reply,
/// the boxed reply sample it owns.
fn rolling_bytes_per_event() -> u64 {
(size_of::<StatsEvent>() + size_of::<ReplySample>()) as u64
(size_of::<RollingEvent>() + size_of::<ReplySample>()) as u64
}
+80 -10
View File
@@ -6,20 +6,58 @@ use std::{
use measurement_stats::CountWindow;
use crate::{
core::CoreStats, normalization::StatsEvent, LateReplyMode, SampleMode, Snapshot, StatsConfig,
core::CoreStats, loss::loss_stats, normalization::StatsEvent, LateReplyMode, SampleMode,
Snapshot, StatsConfig,
};
type ArrivalKey = (Instant, usize);
// Capture the observed cumulative position before eviction can discard it.
// Packet ordinals also reveal holes left by time filtering backdated events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct DirectionalPosition {
packet_events: u128,
server_received: Option<u64>,
}
impl DirectionalPosition {
fn advance(&mut self, event: &StatsEvent) -> bool {
match event {
StatsEvent::UniqueReply { sample, .. } => {
if let Some(count) = sample.received_count {
let count = u64::from(count);
self.server_received = Some(
self.server_received
.map_or(count, |current| current.max(count)),
);
}
}
StatsEvent::Sent { .. }
| StatsEvent::DuplicateReply { .. }
| StatsEvent::UntrackedLate { .. } => {}
StatsEvent::Loss { .. } | StatsEvent::Warning { .. } => return false,
}
self.packet_events += 1;
true
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RollingEvent {
event: StatsEvent,
before: DirectionalPosition,
}
#[derive(Debug, Clone)]
pub(crate) struct RollingEvents {
time_limit: Option<Duration>,
count_events: Option<CountWindow<StatsEvent>>,
count_events: Option<CountWindow<RollingEvent>>,
// Both indexes contain exactly the live events; expiry removes from both.
time_events: Option<BTreeMap<ArrivalKey, StatsEvent>>,
time_events: Option<BTreeMap<ArrivalKey, RollingEvent>>,
time_expiry: BTreeSet<(Instant, ArrivalKey)>,
time_anchor: Option<Instant>,
late_replies: LateReplyMode,
directional_position: DirectionalPosition,
}
impl PartialEq for RollingEvents {
@@ -34,6 +72,7 @@ impl PartialEq for RollingEvents {
&& self.count_events == other.count_events
&& self.time_anchor == other.time_anchor
&& self.late_replies == other.late_replies
&& self.directional_position == other.directional_position
&& same_time_events
}
}
@@ -47,16 +86,24 @@ impl RollingEvents {
time_expiry: BTreeSet::new(),
time_anchor: None,
late_replies: config.late_replies,
directional_position: DirectionalPosition::default(),
}
}
pub(crate) fn push(&mut self, event: StatsEvent) {
if self.count_events.is_none() && self.time_events.is_none() {
return;
}
let at = event.at();
let before = self.directional_position;
self.directional_position.advance(&event);
let event = RollingEvent { event, before };
if let Some(window) = self.count_events.as_mut() {
window.push(event.clone());
}
if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) {
let anchor = self.time_anchor.map_or(event.at(), |at| at.max(event.at()));
let anchor = self.time_anchor.map_or(at, |previous| previous.max(at));
self.time_anchor = Some(anchor);
let cutoff = anchor.checked_sub(duration);
if let Some(cutoff) = cutoff {
@@ -65,7 +112,7 @@ impl RollingEvents {
let (_, arrival) = self.time_expiry.pop_first().unwrap();
window.remove(&arrival);
}
if event.at() < cutoff {
if at < cutoff {
return;
}
}
@@ -73,7 +120,7 @@ impl RollingEvents {
// arrivals. A newer anchor sorts after all earlier arrivals even
// when expiry reduces the length. No lifetime counter can wrap.
let arrival = (anchor, window.len());
self.time_expiry.insert((event.at(), arrival));
self.time_expiry.insert((at, arrival));
window.insert(arrival, event);
}
}
@@ -92,16 +139,39 @@ impl RollingEvents {
}
/// Recompute a window snapshot under the same normalized semantics as the
/// cumulative collector, including its late-reply measurement policy.
/// cumulative collector, except directional loss uses an interval-relative
/// server count. Raw server counters and all other metrics still replay normally.
fn snapshot_window<'a>(
events: impl Iterator<Item = &'a StatsEvent>,
events: impl Iterator<Item = &'a RollingEvent>,
late_replies: LateReplyMode,
) -> Snapshot {
let mut core = CoreStats::new(SampleMode::RunningOnly, late_replies);
let mut start = None;
let mut end: Option<DirectionalPosition> = None;
let mut contiguous = true;
for event in events {
core.apply(event.clone());
let mut after = event.before;
if after.advance(&event.event) {
start.get_or_insert(event.before);
if end.is_some_and(|previous| previous.packet_events != event.before.packet_events) {
contiguous = false;
}
end = Some(after);
}
core.apply(event.event.clone());
}
core.snapshot()
let server_delta = start.zip(end).and_then(|(start, end)| {
if !contiguous {
return None;
}
let baseline = start
.server_received
.or_else(|| (start.packet_events == 0).then_some(0))?;
end.server_received.map(|count| count - baseline)
});
let mut snapshot = core.snapshot();
snapshot.loss = loss_stats(snapshot.packets, server_delta);
snapshot
}
#[cfg(test)]