Files
irtt-rs/crates/irtt-stats/src/retention.rs
T
Nils Andreas Svee db9bd515bb refactor(stats): retain exact finite timing samples
`SampleMode::Exact` retained exact samples for eight of the eleven public
timing metrics. Send call, timer error, and server processing always built
their `TimeMetric` with retention disabled, so a finite run reported no
median for them even though they are the same public `TimeStats` type and
appear in the same summary table with a Median column.

Derive one `retain_exact` decision from the sample mode in `CoreStats::new`
and apply it to every timing metric, so exact mode means the same thing for
all of them. Running-only mode is unchanged and still retains none of them.

The retention estimator counted eight exact sample streams per measurable
reply. Rename it to the eleven exact samples an ordinary successful probe
can retain and document the wider optional-measurement upper bound, which
raises the finite estimate on this target from 668 to 764 bytes per probe.
A test counts the metrics a fully measured probe actually retains and
compares it against that constant, so retention and its estimate cannot
drift apart unnoticed.

No new median-capability type was needed: `TimeStats::median_ns` already
distinguishes a retained median from an absent one, and an optional
measurement the server never supplied still produces no samples at all.
2026-08-15 10:41:17 +07:00

254 lines
9.6 KiB
Rust

//! Approximate estimates of the storage a statistics configuration retains.
//!
//! The estimate is derived from the structures this crate actually keeps, so
//! retention changes and the planning estimate stay in one place. It models
//! live element storage plus a fixed capacity-headroom factor; it is not
//! allocator accounting.
use std::mem::size_of;
use crate::core::CONTINUOUS_SEQUENCE_LIMIT;
use crate::ipdv::IpdvSample;
use crate::normalization::{ReplySample, StatsEvent};
use crate::{SampleMode, StatsConfig};
/// Capacity headroom applied to the live element bytes.
///
/// `Vec`, `VecDeque`, `HashMap`, and `HashSet` all grow by doubling, so a
/// container can hold up to roughly twice the bytes its live elements need.
/// Assuming the worst case keeps the estimate conservative rather than
/// falsely precise.
const GROWTH_FACTOR: u64 = 2;
/// Approximate per-entry control overhead for the hashed containers.
const HASH_ENTRY_OVERHEAD: u64 = 1;
/// Exact timing samples an ordinary successful probe can retain.
///
/// In [`SampleMode::Exact`] every public timing metric retains its samples, so
/// one probe that is sent and answered can add one `i128` to each of them:
/// send call and timer error from the send, and primary, raw, and adjusted
/// RTT, the three IPDV metrics, the two one-way delays, and server processing
/// from the reply. [`SampleMode::RunningOnly`] retains none of them.
///
/// Adjusted RTT, one-way delay, send/receive IPDV, and server processing only
/// receive a sample when the negotiated session supplies the corresponding
/// optional measurement, so counting all eleven is the upper bound.
///
/// This count must track the metrics `CoreStats::new` builds with exact
/// retention. `exact_mode_retains_one_sample_per_counted_metric` below counts
/// the metrics a fully measured probe actually retains, so the two cannot
/// drift apart unnoticed.
pub(crate) const EXACT_SAMPLES_PER_PROBE: u64 = 11;
/// Normalized events a probe usually contributes to a rolling window: one send
/// event and one unique reply event.
const ROLLING_EVENTS_PER_PROBE: u64 = 2;
/// Returns the approximate bytes retained after `probe_count` probes.
pub(crate) fn estimated_retained_bytes(config: &StatsConfig, probe_count: u64) -> u64 {
let cumulative = match config.samples {
SampleMode::Exact => exact_bytes(probe_count),
SampleMode::RunningOnly => running_only_bytes(probe_count),
};
cumulative
.saturating_add(rolling_count_bytes(config, probe_count))
.saturating_mul(GROWTH_FACTOR)
}
/// Exact mode retains one sample per timing metric and one IPDV tracker entry
/// for every ordinary successful probe, so its storage grows with the probe
/// count.
fn exact_bytes(probe_count: u64) -> u64 {
let per_probe = EXACT_SAMPLES_PER_PROBE
.saturating_mul(size_of::<i128>() as u64)
.saturating_add(ipdv_tracker_bytes_per_sample());
probe_count.saturating_mul(per_probe)
}
/// Running-only mode retains no exact samples and bounds the IPDV tracker at a
/// fixed number of sequences, so its storage stops growing once that bound is
/// reached.
fn running_only_bytes(probe_count: u64) -> u64 {
probe_count
.min(CONTINUOUS_SEQUENCE_LIMIT as u64)
.saturating_mul(ipdv_tracker_bytes_per_sample())
}
/// The IPDV tracker keys a sample map by sequence and keeps a sequence order
/// queue and a completed-pair set alongside it.
fn ipdv_tracker_bytes_per_sample() -> u64 {
let sample_entry = (size_of::<u32>() + size_of::<IpdvSample>()) as u64 + HASH_ENTRY_OVERHEAD;
let order_entry = size_of::<u32>() as u64;
let completed_entry = size_of::<u32>() as u64 + HASH_ENTRY_OVERHEAD;
sample_entry
.saturating_add(order_entry)
.saturating_add(completed_entry)
}
/// Count-based rolling windows retain whole normalized events, bounded by the
/// configured event count.
fn rolling_count_bytes(config: &StatsConfig, probe_count: u64) -> u64 {
let Some(limit) = config.rolling_count else {
return 0;
};
let retained = probe_count
.saturating_mul(ROLLING_EVENTS_PER_PROBE)
.min(limit as 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.
fn rolling_bytes_per_event() -> u64 {
(size_of::<StatsEvent>() + size_of::<ReplySample>()) as u64
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample,
RttSample, ServerTiming, SignedDuration,
};
use super::*;
use crate::{Snapshot, StatsCollector, TimeStats};
/// Every public timing metric a snapshot exposes.
fn timing_metrics(snapshot: &Snapshot) -> Vec<&TimeStats> {
vec![
&snapshot.send_call,
&snapshot.timer_error,
&snapshot.rtt.primary,
&snapshot.rtt.raw,
&snapshot.rtt.adjusted,
&snapshot.ipdv.round_trip,
&snapshot.ipdv.send,
&snapshot.ipdv.receive,
&snapshot.one_way_delay.send_delay,
&snapshot.one_way_delay.receive_delay,
&snapshot.server_processing.processing,
]
}
fn timestamp(offset_ms: u64) -> ClientTimestamp {
ClientTimestamp {
mono: Instant::now() + Duration::from_millis(offset_ms),
wall: UNIX_EPOCH + Duration::from_millis(offset_ms),
}
}
fn sent(seq: u32) -> ClientEvent {
let sent_at = timestamp(u64::from(seq) * 10);
ClientEvent::EchoSent {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 32,
send_call: Duration::from_micros(10 + u64::from(seq)),
timer_error: Duration::from_micros(2 + u64::from(seq)),
}
}
/// A reply carrying every optional measurement, so one probe exercises the
/// upper bound the estimate assumes.
fn fully_measured_reply(seq: u32) -> ClientEvent {
let sent_at = timestamp(u64::from(seq) * 10);
let raw = Duration::from_millis(10 + u64::from(seq));
let received_at = ClientTimestamp {
mono: sent_at.mono + raw,
wall: sent_at.wall + raw,
};
let base_ns = i64::from(seq) * 10_000_000;
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw,
adjusted: Some(SignedDuration::from_duration(raw)),
effective: SignedDuration::from_duration(raw),
},
server_timing: Some(ServerTiming {
receive_mono_ns: Some(base_ns + 1_000_000),
send_mono_ns: Some(base_ns + 2_000_000),
receive_wall_ns: Some(base_ns + 1_000_000),
send_wall_ns: Some(base_ns + 2_000_000),
midpoint_mono_ns: None,
midpoint_wall_ns: None,
processing: Some(Duration::from_millis(1 + u64::from(seq))),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(1_000_000 + i128::from(seq))),
server_to_client: Some(SignedDuration::from_nanos(2_000_000 + i128::from(seq))),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1),
window: Some(0xff),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
/// Two probes fill every metric, including the IPDV pair metrics that need
/// adjacent replies.
fn fully_measured_snapshot(config: StatsConfig) -> Snapshot {
let mut collector = StatsCollector::new(config);
for seq in 0..2 {
collector.process(&sent(seq));
collector.process(&fully_measured_reply(seq));
}
collector.snapshot()
}
/// The estimate is only honest while it counts the metrics that actually
/// retain samples. Adding or removing an exact-retaining public timing
/// metric without revisiting [`EXACT_SAMPLES_PER_PROBE`] fails here.
#[test]
fn exact_mode_retains_one_sample_per_counted_metric() {
let snapshot = fully_measured_snapshot(StatsConfig::finite());
let with_medians = timing_metrics(&snapshot)
.into_iter()
.filter(|stats| stats.median_ns.is_some())
.count() as u64;
assert_eq!(
with_medians, EXACT_SAMPLES_PER_PROBE,
"the estimate counts {EXACT_SAMPLES_PER_PROBE} exact sample streams per probe, \
but {with_medians} public timing metrics retained samples"
);
}
#[test]
fn running_only_retains_no_exact_samples() {
let snapshot = fully_measured_snapshot(StatsConfig::continuous());
for stats in timing_metrics(&snapshot) {
assert!(stats.count > 0, "the fixture should populate every metric");
assert_eq!(
stats.median_ns, None,
"running-only mode retains no exact samples"
);
}
}
#[test]
fn per_sample_components_are_non_zero() {
assert!(ipdv_tracker_bytes_per_sample() > 0);
assert!(rolling_bytes_per_event() > 0);
}
#[test]
fn running_only_stops_growing_at_the_bounded_sequence_limit() {
let limit = CONTINUOUS_SEQUENCE_LIMIT as u64;
assert_eq!(running_only_bytes(limit), running_only_bytes(limit * 100));
assert!(running_only_bytes(limit - 1) < running_only_bytes(limit));
}
}