zenkey_fleet/model/retain.rs
1//! The retained window (issue #217): a bounded ring of recent samples on
2//! the monitor's ingest path, so "scrub back to before you noticed" needs
3//! no recording to have been running.
4//!
5//! Two budgets, both in force at once — **bytes** and **duration** — because
6//! each fails alone: a byte budget on a quiet bus retains stale hours, a
7//! duration budget on a hot one retains an unbounded burst. The defaults are
8//! deliberately small ([`RetentionBudget::default`]: 64 MiB / 2 min) — a
9//! generous default here turns an overnight session into a memory incident.
10//!
11//! **What eviction from this ring is, and is not** (RFC 09 §5.1 **O6**,
12//! sharpened at ratification — v1.18 R1): a bounded observer reports what
13//! each bound cost and MUST NOT fold the kinds into one number. The ring's
14//! costs are therefore counted apart from every existing population — from
15//! broadcast lag ([`crate::MonitorCore::dropped`], "could not keep up"),
16//! from stats-table eviction ([`crate::model::stats::StatsTable::evicted`], "chose
17//! to forget under the key bound") and from unwatch retirement
18//! ([`crate::model::stats::StatsTable::unwatched`], "stopped looking, by request")
19//! — and the ring itself keeps its own two kinds apart:
20//!
21//! - [`RetentionStats::evicted`] — samples dropped because the **byte**
22//! budget bit. The window is then *narrower than the age claim*, which is
23//! exactly what a consumer must be told before trusting "the last 2 min".
24//! - [`RetentionStats::expired`] — samples that aged past the duration
25//! budget: the window sliding exactly as declared, not a loss against the
26//! claim, and still counted rather than silently absorbed.
27//!
28//! The ring holds `Arc<SampleView>` — retaining a sample is a refcount bump
29//! on zenoh's refcounted buffers, not a copy (`docs/zero-copy.md` §4); the
30//! byte budget accounts the payload bytes those Arcs keep alive.
31//!
32//! ## Why the ring is chunked (#331)
33//!
34//! The ring lives behind [`crate::MonitorCore`]'s retain mutex, which
35//! `ingest` takes on **zenoh's network callback thread**. A read that cloned
36//! the whole `VecDeque` therefore stalled the network layer for one refcount
37//! atomic per retained sample — ~260 000 of them at the default budget — and
38//! zengui called it from `update()`, twice in a row, per retained-window
39//! entry.
40//!
41//! So the ring is a queue of **sealed, immutable chunks** (1024 samples
42//! each) plus one open tail. A read clones the chunk pointers and the tail
43//! (`RetainedParts`) — bounded by `window / CHUNK + CHUNK` pointer clones,
44//! ~1 300 atomics at the same budget, and *nothing* that grows with the
45//! payload — and flattens them into the handed-out `Arc<[_]>` after the lock
46//! is released. Pushes stay O(1) amortised: a chunk is sealed once per
47//! `CHUNK` samples, which is a `drain` into an `Arc<[_]>` and nothing else.
48
49use std::collections::VecDeque;
50use std::sync::Arc;
51use std::time::{Duration, Instant};
52
53use crate::bus::monitor::SampleView;
54
55/// The two bounds on the retained window, both always in force.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct RetentionBudget {
58 /// Accounted bytes the ring may hold ([`sample_cost`] per sample).
59 pub max_bytes: usize,
60 /// How far back the window reaches, on the observer's arrival clock
61 /// ([`SampleView::received`]).
62 pub max_age: Duration,
63}
64
65impl Default for RetentionBudget {
66 /// 64 MiB / 2 min — small on purpose (#217): the budget is visible in
67 /// the GUI's status strip, and an operator who wants more says so.
68 fn default() -> RetentionBudget {
69 RetentionBudget {
70 max_bytes: 64 * 1024 * 1024,
71 max_age: Duration::from_secs(120),
72 }
73 }
74}
75
76/// What the ring holds and what its bounds have cost, as of one read.
77///
78/// All `Copy`: the monitor hands this out per stats tick, and a snapshot
79/// that allocated would contend with the ingest path for nothing.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct RetentionStats {
82 /// The budget in force — the banner states it (#217).
83 pub budget: RetentionBudget,
84 /// Samples currently retained.
85 pub retained: usize,
86 /// Accounted bytes currently retained.
87 pub retained_bytes: usize,
88 /// Oldest retained sample → newest, on the arrival clock. Zero when
89 /// fewer than two samples are held. When [`RetentionStats::evicted`] is
90 /// non-zero this is shorter than `budget.max_age` claims — which is why
91 /// both ride together.
92 pub span: Duration,
93 /// Samples dropped because the **byte** budget bit (O6: the bound's
94 /// cost). Counted apart from [`RetentionStats::expired`], from broadcast
95 /// lag, and from both stats-table populations — v1.18 R1 forbids the
96 /// fold.
97 pub evicted: u64,
98 /// Samples that aged past `budget.max_age` — the window sliding as
99 /// declared.
100 pub expired: u64,
101}
102
103/// What one retained sample costs the byte budget: the bytes its `Arc`
104/// keeps alive (payload, attachment, key) plus a flat allowance for the
105/// view struct itself. An estimate on the honest side of exact — a budget
106/// that ignored what it stores would stop being a bound (O6).
107pub fn sample_cost(view: &SampleView) -> usize {
108 const OVERHEAD: usize = 160;
109 view.key.len()
110 + view.payload.len()
111 + view.encoding.len()
112 + view
113 .attachment
114 .as_ref()
115 .map_or(0, zenoh::bytes::ZBytes::len)
116 + OVERHEAD
117}
118
119/// Samples per sealed chunk — the granularity a read pays for (#331).
120///
121/// 1024 is the trade: a read clones `window / 1024` chunk pointers plus at
122/// most 1024 tail pointers, and a push seals a chunk once per 1024 samples.
123/// Both sides of that stay in the low thousands of atomics at any budget an
124/// explorer is given.
125pub(crate) const CHUNK: usize = 1024;
126
127/// A read of the ring, taken **under** the mutex and flattened outside it
128/// (#331): sealed chunk pointers, how far into the first one the window
129/// starts, and a copy of the open tail. Cloning this is bounded by
130/// `window / CHUNK + CHUNK` pointer clones; nothing in it walks the window.
131pub(crate) struct RetainedParts {
132 sealed: Vec<Arc<[Arc<SampleView>]>>,
133 front: usize,
134 tail: Vec<Arc<SampleView>>,
135 len: usize,
136}
137
138impl RetainedParts {
139 /// How many sealed chunk pointers this read holds, plus one for the
140 /// tail — the whole cost paid under the ingest mutex, and the number a
141 /// test can assert instead of a stopwatch (#331). Test-only: the
142 /// production path never needs to count what it is about to flatten.
143 #[cfg(test)]
144 pub(crate) fn chunks(&self) -> usize {
145 self.sealed.len() + 1
146 }
147
148 /// The window, oldest first. O(window) — which is why it happens with
149 /// the ingest mutex released.
150 pub(crate) fn flatten(self) -> Arc<[Arc<SampleView>]> {
151 let mut out: Vec<Arc<SampleView>> = Vec::with_capacity(self.len);
152 for (i, chunk) in self.sealed.iter().enumerate() {
153 let from = if i == 0 { self.front } else { 0 };
154 out.extend(chunk[from..].iter().cloned());
155 }
156 out.extend(self.tail);
157 Arc::from(out)
158 }
159}
160
161/// The ring itself. Owned by [`crate::MonitorCore`] behind its own mutex;
162/// everything here is synchronous and allocation-light.
163///
164/// Sealed chunks and an open tail rather than one `VecDeque`, so that a read
165/// is bounded work under that mutex — see the module header (#331).
166#[derive(Debug)]
167pub(crate) struct Retention {
168 budget: RetentionBudget,
169 /// Sealed chunks, oldest first. Immutable once sealed, which is what
170 /// makes handing one out a pointer clone.
171 sealed: VecDeque<Arc<[Arc<SampleView>]>>,
172 /// Samples already evicted from the front of the oldest sealed chunk.
173 front: usize,
174 /// The chunk being filled. Sealed at [`CHUNK`] samples.
175 tail: VecDeque<Arc<SampleView>>,
176 /// Samples held across both — `sealed` cannot report its own length
177 /// cheaply once `front` is non-zero.
178 len: usize,
179 bytes: usize,
180 evicted: u64,
181 expired: u64,
182}
183
184impl Retention {
185 pub(crate) fn new(budget: RetentionBudget) -> Retention {
186 Retention {
187 budget,
188 sealed: VecDeque::new(),
189 front: 0,
190 tail: VecDeque::new(),
191 len: 0,
192 bytes: 0,
193 evicted: 0,
194 expired: 0,
195 }
196 }
197
198 /// Change the budget in force; the next push or read applies it.
199 pub(crate) fn set_budget(&mut self, budget: RetentionBudget) {
200 self.budget = budget;
201 }
202
203 /// Retain one sample, then enforce both budgets (oldest out first).
204 pub(crate) fn push(&mut self, view: Arc<SampleView>, now: Instant) {
205 self.bytes += sample_cost(&view);
206 self.tail.push_back(view);
207 self.len += 1;
208 if self.tail.len() >= CHUNK {
209 self.sealed.push_back(self.tail.drain(..).collect());
210 }
211 self.expire(now);
212 while self.bytes > self.budget.max_bytes && self.len > 1 {
213 self.pop_front();
214 self.evicted += 1;
215 }
216 // A single sample larger than the whole budget is retained anyway
217 // and honestly accounted: a window that silently held nothing would
218 // read as a quiet bus.
219 }
220
221 /// The oldest retained sample, wherever it lives.
222 fn oldest(&self) -> Option<&Arc<SampleView>> {
223 match self.sealed.front() {
224 Some(chunk) => chunk.get(self.front),
225 None => self.tail.front(),
226 }
227 }
228
229 /// The newest retained sample, wherever it lives.
230 fn newest(&self) -> Option<&Arc<SampleView>> {
231 match self.tail.back() {
232 Some(view) => Some(view),
233 None => self.sealed.back().and_then(|chunk| chunk.last()),
234 }
235 }
236
237 fn pop_front(&mut self) {
238 let popped = match self.sealed.front() {
239 Some(chunk) => {
240 let view = Arc::clone(&chunk[self.front]);
241 self.front += 1;
242 if self.front >= chunk.len() {
243 self.sealed.pop_front();
244 self.front = 0;
245 }
246 Some(view)
247 }
248 None => self.tail.pop_front(),
249 };
250 if let Some(v) = popped {
251 self.bytes = self.bytes.saturating_sub(sample_cost(&v));
252 self.len -= 1;
253 }
254 }
255
256 /// Age out everything past the duration budget.
257 fn expire(&mut self, now: Instant) {
258 while self
259 .oldest()
260 .is_some_and(|v| now.saturating_duration_since(v.received) > self.budget.max_age)
261 {
262 self.pop_front();
263 self.expired += 1;
264 }
265 }
266
267 /// The window as chunk pointers — bounded work, for the caller to
268 /// flatten once the mutex is released (#331).
269 pub(crate) fn parts(&mut self, now: Instant) -> RetainedParts {
270 self.expire(now);
271 RetainedParts {
272 sealed: self.sealed.iter().map(Arc::clone).collect(),
273 front: self.front,
274 tail: self.tail.iter().map(Arc::clone).collect(),
275 len: self.len,
276 }
277 }
278
279 /// The window, oldest first — both halves in one call, for the module's
280 /// own tests and for callers that hold the ring exclusively.
281 #[cfg(test)]
282 pub(crate) fn snapshot(&mut self, now: Instant) -> Arc<[Arc<SampleView>]> {
283 self.parts(now).flatten()
284 }
285
286 /// The window's account of itself, budgets applied as of `now`.
287 pub(crate) fn stats(&mut self, now: Instant) -> RetentionStats {
288 self.expire(now);
289 let span = match (self.oldest(), self.newest()) {
290 (Some(oldest), Some(newest)) => {
291 newest.received.saturating_duration_since(oldest.received)
292 }
293 _ => Duration::ZERO,
294 };
295 RetentionStats {
296 budget: self.budget,
297 retained: self.len,
298 retained_bytes: self.bytes,
299 span,
300 evicted: self.evicted,
301 expired: self.expired,
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use zenoh::sample::SampleKind;
310
311 fn view(key: &str, len: usize, received: Instant) -> Arc<SampleView> {
312 Arc::new(SampleView {
313 key: key.to_string(),
314 payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
315 encoding: String::new(),
316 kind: SampleKind::Put,
317 timestamp: None,
318 stamped_by: None,
319 attachment: None,
320 priority: zenoh::qos::Priority::DEFAULT,
321 congestion_control: zenoh::qos::CongestionControl::DEFAULT,
322 reliability: zenoh::qos::Reliability::DEFAULT,
323 express: false,
324 source: None,
325 received,
326 })
327 }
328
329 /// The default is the small one the issue names — and the banner states
330 /// it, so a silent change here would make the banner lie.
331 #[test]
332 fn the_default_budget_is_64_mib_and_two_minutes() {
333 let b = RetentionBudget::default();
334 assert_eq!(b.max_bytes, 64 * 1024 * 1024);
335 assert_eq!(b.max_age, Duration::from_secs(120));
336 }
337
338 /// The byte budget evicts oldest-first and counts what it cost (O6).
339 #[test]
340 fn the_byte_budget_evicts_oldest_first_and_counts() {
341 let now = Instant::now();
342 let mut r = Retention::new(RetentionBudget {
343 max_bytes: 3 * sample_cost(&view("k", 100, now)),
344 max_age: Duration::from_secs(3600),
345 });
346 for i in 0..10 {
347 r.push(view(&format!("k{i}"), 100 - i, now), now);
348 }
349 let s = r.stats(now);
350 assert!(s.retained < 10, "the bound bit");
351 assert_eq!(s.retained as u64 + s.evicted, 10, "present or counted");
352 assert_eq!(s.expired, 0, "nothing aged out — the kinds stay apart");
353 let kept = r.snapshot(now);
354 assert_eq!(kept.first().unwrap().key, format!("k{}", 10 - kept.len()));
355 assert_eq!(kept.last().unwrap().key, "k9", "newest survives");
356 }
357
358 /// The duration budget is the window sliding as declared — counted as
359 /// `expired`, never folded into `evicted` (v1.18 R1).
360 #[test]
361 fn aging_out_is_expiry_not_eviction() {
362 let t0 = Instant::now();
363 let mut r = Retention::new(RetentionBudget {
364 max_bytes: usize::MAX,
365 max_age: Duration::from_secs(10),
366 });
367 r.push(view("old", 4, t0), t0);
368 r.push(
369 view("new", 4, t0 + Duration::from_secs(20)),
370 t0 + Duration::from_secs(20),
371 );
372 let s = r.stats(t0 + Duration::from_secs(20));
373 assert_eq!(s.retained, 1);
374 assert_eq!(s.expired, 1);
375 assert_eq!(s.evicted, 0, "no byte bound bit — two kinds, two numbers");
376 assert_eq!(r.snapshot(t0 + Duration::from_secs(20))[0].key, "new");
377 }
378
379 /// A sample larger than the whole byte budget is retained and accounted
380 /// rather than silently refused — an empty window must never be
381 /// manufactured by the bound.
382 #[test]
383 fn one_oversized_sample_is_held_not_hidden() {
384 let now = Instant::now();
385 let mut r = Retention::new(RetentionBudget {
386 max_bytes: 8,
387 max_age: Duration::from_secs(3600),
388 });
389 r.push(view("big", 1024, now), now);
390 let s = r.stats(now);
391 assert_eq!(s.retained, 1);
392 assert!(
393 s.retained_bytes > s.budget.max_bytes,
394 "over budget, and said so"
395 );
396 }
397
398 /// The chunking is invisible from the outside (#331): order, length and
399 /// the span read the same across a sealed boundary as inside one chunk.
400 #[test]
401 fn the_window_reads_the_same_across_chunk_boundaries() {
402 let now = Instant::now();
403 let mut r = Retention::new(RetentionBudget {
404 max_bytes: usize::MAX,
405 max_age: Duration::from_secs(3600),
406 });
407 let total = CHUNK * 2 + 7;
408 for i in 0..total {
409 r.push(view(&format!("k{i:05}"), 8, now), now);
410 }
411 let kept = r.snapshot(now);
412 assert_eq!(kept.len(), total);
413 assert_eq!(kept[0].key, "k00000");
414 assert_eq!(kept[CHUNK].key, format!("k{CHUNK:05}"), "the seam holds");
415 assert_eq!(kept.last().unwrap().key, format!("k{:05}", total - 1));
416 assert_eq!(r.stats(now).retained, total);
417 }
418
419 /// Eviction walks *into* a sealed chunk rather than dropping it whole:
420 /// the byte budget's granularity is one sample, chunked or not.
421 #[test]
422 fn eviction_walks_into_a_sealed_chunk() {
423 let now = Instant::now();
424 let keep = CHUNK + 5;
425 let mut r = Retention::new(RetentionBudget {
426 max_bytes: sample_cost(&view("k00000", 8, now)) * keep,
427 max_age: Duration::from_secs(3600),
428 });
429 let total = CHUNK * 3;
430 for i in 0..total {
431 r.push(view(&format!("k{i:05}"), 8, now), now);
432 }
433 let s = r.stats(now);
434 assert_eq!(s.retained, keep, "the bound bit mid-chunk");
435 assert_eq!(
436 s.retained as u64 + s.evicted,
437 total as u64,
438 "present or counted"
439 );
440 let kept = r.snapshot(now);
441 assert_eq!(kept.len(), keep);
442 assert_eq!(kept[0].key, format!("k{:05}", total - keep));
443 assert_eq!(kept.last().unwrap().key, format!("k{:05}", total - 1));
444 }
445
446 /// The span states what the window actually holds — which is shorter
447 /// than the age claim exactly when `evicted` is non-zero.
448 #[test]
449 fn the_span_is_measured_not_claimed() {
450 let t0 = Instant::now();
451 let mut r = Retention::new(RetentionBudget::default());
452 r.push(view("a", 4, t0), t0);
453 r.push(
454 view("b", 4, t0 + Duration::from_secs(30)),
455 t0 + Duration::from_secs(30),
456 );
457 assert_eq!(
458 r.stats(t0 + Duration::from_secs(30)).span,
459 Duration::from_secs(30)
460 );
461 }
462}