fix(stats): align rolling directional loss with window counts
This commit is contained in:
8 files changed
+489
-34
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user