Skip to main content

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}