refactor(tui): compact graph history and retain 500k samples
This commit is contained in:
2 files changed
+99
-44
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user