refactor(tui): compact graph history and retain 500k samples

This commit is contained in:
Nils Andreas Svee committed 2026-10-07 12:41:49 +07:00
1 parent 988a360fef
commit 9e6c10c083
2 files changed
+99 -44

No files matched your search

+88 -42
View File
@@ -43,7 +43,7 @@ use crate::{
shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup}, shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup},
}; };
const HISTORY_LIMIT: usize = 100_000; const HISTORY_LIMIT: usize = 500_000;
const RECENT_EVENT_LIMIT: usize = 80; const RECENT_EVENT_LIMIT: usize = 80;
const MIN_WIDTH: u16 = 56; const MIN_WIDTH: u16 = 56;
const MIN_HEIGHT: u16 = 18; const MIN_HEIGHT: u16 = 18;
@@ -405,7 +405,7 @@ impl TuiState {
server_timing, server_timing,
.. ..
} => { } => {
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation: target.generation.expect("client event has a generation"), generation: target.generation.expect("client event has a generation"),
timestamp: *received_at, timestamp: *received_at,
seq: *seq, seq: *seq,
@@ -615,7 +615,7 @@ struct TuiTargetState {
status: TargetStatus, status: TargetStatus,
negotiated: Option<NegotiationResult>, negotiated: Option<NegotiationResult>,
graph_history: VecDeque<GraphSample>, graph_history: VecDeque<GraphSample>,
last_sample: Option<GraphSample>, last_sample: Option<LatestSample>,
last_warning: Option<String>, last_warning: Option<String>,
stats: StatsCollector, stats: StatsCollector,
} }
@@ -656,9 +656,20 @@ impl TuiTargetState {
} }
} }
fn push_graph_sample(&mut self, sample: GraphSample) { fn push_graph_sample(&mut self, sample: LatestSample) {
let history = GraphSample {
timestamp: sample.timestamp,
values_ms: GraphMetric::ALL.map(|metric| {
metric
.value_ns(&sample)
.map_or(f64::NAN, |ns| ns as f64 / 1_000_000.0)
}),
break_before: self
.last_sample
.is_none_or(|last| last.generation != sample.generation),
};
self.last_sample = Some(sample); self.last_sample = Some(sample);
push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT); push_bounded(&mut self.graph_history, history, HISTORY_LIMIT);
} }
} }
@@ -926,7 +937,8 @@ enum GraphMetric {
} }
impl GraphMetric { impl GraphMetric {
const ALL: &[Self] = &[ // Enum order also indexes the retained graph values.
const ALL: [Self; 6] = [
Self::EffectiveRtt, Self::EffectiveRtt,
Self::RawRtt, Self::RawRtt,
Self::AdjustedRtt, Self::AdjustedRtt,
@@ -966,7 +978,7 @@ impl GraphMetric {
} }
} }
fn value_ns(self, sample: &GraphSample) -> Option<i128> { fn value_ns(self, sample: &LatestSample) -> Option<i128> {
match self { match self {
Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()), Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()),
Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()), Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()),
@@ -996,8 +1008,25 @@ impl GraphMetric {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] // Retained values use the same nanoseconds-to-f64 milliseconds conversion as
// the chart. NaN means absent and never reaches the canvas as a finite point.
// Keep both clocks: geometry is monotonic, while labels use local wall time.
#[derive(Debug, Clone, Copy)]
struct GraphSample { struct GraphSample {
timestamp: ClientTimestamp,
values_ms: [f64; 6],
break_before: bool,
}
impl GraphSample {
fn value_ms(&self, metric: GraphMetric) -> Option<f64> {
let value = self.values_ms[metric as usize];
value.is_finite().then_some(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LatestSample {
generation: u64, generation: u64,
timestamp: ClientTimestamp, timestamp: ClientTimestamp,
seq: u32, seq: u32,
@@ -1426,40 +1455,31 @@ fn target_metric_series(
let first = target let first = target
.graph_history .graph_history
.partition_point(|sample| sample.timestamp.mono < viewport.start); .partition_point(|sample| sample.timestamp.mono < viewport.start);
let predecessor_generation = target let mut previous = None;
.graph_history for sample in target.graph_history.range(..first).rev() {
.get(first.saturating_sub(1)) if let Some(value) = sample.value_ms(metric) {
.map(|sample| sample.generation); previous = Some((sample.timestamp.mono, value));
let mut previous = target break;
.graph_history }
.range(..first) if sample.break_before {
.rev() break;
.take_while(|sample| Some(sample.generation) == predecessor_generation) }
.find_map(|sample| { }
metric.value_ns(sample).map(|ns| {
(
sample.generation,
(sample.timestamp.mono, ns as f64 / 1_000_000.0),
)
})
});
let points = target.graph_history.range(first..).filter_map(|sample| {
metric.value_ns(sample).map(|ns| {
(
sample.generation,
(sample.timestamp.mono, ns as f64 / 1_000_000.0),
)
})
});
let mut data = Vec::new(); let mut data = Vec::new();
for (generation, point) in points { for sample in target.graph_history.range(first..) {
if previous.is_some_and(|(before_generation, _)| before_generation != generation) { // Consume boundaries even when this metric is absent in the first
if !data.is_empty() { // replies of a new session; filtering them out would bridge an outage.
if sample.break_before {
if !data.is_empty() && data.last().is_some_and(|(x, _): &(f64, f64)| x.is_finite()) {
data.push((f64::NAN, f64::NAN)); data.push((f64::NAN, f64::NAN));
} }
previous = None; previous = None;
} }
if let Some((_, before)) = previous { let Some(value) = sample.value_ms(metric) else {
continue;
};
let point = (sample.timestamp.mono, value);
if let Some(before) = previous {
// Only genuine crossing segments contribute boundary points. In // Only genuine crossing segments contribute boundary points. In
// particular, never extend the latest sample to the live edge. // particular, never extend the latest sample to the live edge.
if before.0 < viewport.start && point.0 > viewport.start { if before.0 < viewport.start && point.0 > viewport.start {
@@ -1481,7 +1501,7 @@ fn target_metric_series(
point.1, point.1,
)); ));
} }
previous = Some((generation, point)); previous = Some(point);
} }
// A boundary beyond the viewport needs no trailing break. // A boundary beyond the viewport needs no trailing break.
if data.last().is_some_and(|(x, _)| !x.is_finite()) { if data.last().is_some_and(|(x, _)| !x.is_finite()) {
@@ -2032,7 +2052,7 @@ mod tests {
let mut target = TuiTargetState::new("monitor".into(), stats_config(true)); let mut target = TuiTargetState::new("monitor".into(), stats_config(true));
for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] { for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] {
let raw = Duration::from_millis(seconds + 1); let raw = Duration::from_millis(seconds + 1);
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation, generation,
timestamp: ClientTimestamp { timestamp: ClientTimestamp {
mono: start + Duration::from_secs(seconds), mono: start + Duration::from_secs(seconds),
@@ -2041,7 +2061,8 @@ mod tests {
seq: 0, seq: 0,
rtt: RttSample { rtt: RttSample {
raw, raw,
adjusted: None, adjusted: (seconds != 6)
.then_some(SignedDuration::from_nanos(-(raw.as_nanos() as i128))),
effective: SignedDuration::from_nanos(raw.as_nanos() as i128), effective: SignedDuration::from_nanos(raw.as_nanos() as i128),
}, },
one_way: None, one_way: None,
@@ -2110,10 +2131,35 @@ mod tests {
); );
assert!(series(3, 5).is_none(), "no interpolation across sessions"); assert!(series(3, 5).is_none(), "no interpolation across sessions");
assert!(series(9, 10).is_none(), "no extrapolation to the live edge"); assert!(series(9, 10).is_none(), "no extrapolation to the live edge");
let template = target.graph_history[0]; let adjusted = |left, right| {
target_metric_series(
&target,
0,
GraphViewportRange {
start: start + Duration::from_secs(left),
end: start + Duration::from_secs(right),
window: Duration::from_secs(right - left),
},
GraphMetric::AdjustedRtt,
)
};
let missing = adjusted(1, 9).unwrap();
assert_eq!(&missing.data[..2], [(0.0, -2.0), (1.0, -3.0)]);
assert!(missing.data[2].0.is_nan());
assert_eq!(&missing.data[3..], [(7.0, -9.0)]);
assert!(
adjusted(3, 7).is_none(),
"absent metric cannot bridge sessions"
);
assert_eq!(
adjusted(7, 9).unwrap().data,
[(1.0, -9.0)],
"predecessor search stops at an absent boundary metric"
);
let template = target.last_sample.unwrap();
target.graph_history.clear(); target.graph_history.clear();
for generation in 0..10_000 { for generation in 0..10_000 {
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation, generation,
timestamp: ClientTimestamp { timestamp: ClientTimestamp {
mono: start + Duration::from_secs(generation), mono: start + Duration::from_secs(generation),
+11 -2
View File
@@ -108,11 +108,20 @@ controls appropriate to the currently open graph or details sheet.
The TUI keeps its own bounded presentation state on top of the statistics The TUI keeps its own bounded presentation state on top of the statistics
retention described in `irtt-client(1)`: retention described in `irtt-client(1)`:
- Up to 100,000 graph samples per target. - Up to 500,000 graph samples per target.
- Up to 80 recent status/log messages. - Up to 80 recent status/log messages.
At 500,000 replies per target, retention is approximately 13h53m20s at
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph
samples store both clocks, six graph-ready values, and a session-boundary
flag; latest details keep the full typed timing values and sequence number.
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target
at the cap, excluding statistics and temporary rendering buffers. Histories
grow on demand and reserve exactly to the cap on their final growth step;
they do not preallocate the full history for every target at startup.
`r` clears graph history for all targets and returns the viewport to `r` clears graph history for all targets and returns the viewport to
live; it does not change the 100,000-sample cap itself. These are live; it does not change the 500,000-sample cap itself. These are
presentation bounds on top of the client's own statistics retention (see presentation bounds on top of the client's own statistics retention (see
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's `irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
total memory is dominated by whichever of the two is larger for your target total memory is dominated by whichever of the two is larger for your target