use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::bus::monitor::SampleView;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionBudget {
pub max_bytes: usize,
pub max_age: Duration,
}
impl Default for RetentionBudget {
fn default() -> RetentionBudget {
RetentionBudget {
max_bytes: 64 * 1024 * 1024,
max_age: Duration::from_secs(120),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionStats {
pub budget: RetentionBudget,
pub retained: usize,
pub retained_bytes: usize,
pub span: Duration,
pub evicted: u64,
pub expired: u64,
}
pub fn sample_cost(view: &SampleView) -> usize {
const OVERHEAD: usize = 160;
view.key.len()
+ view.payload.len()
+ view.encoding.len()
+ view
.attachment
.as_ref()
.map_or(0, zenoh::bytes::ZBytes::len)
+ OVERHEAD
}
pub(crate) const CHUNK: usize = 1024;
pub(crate) struct RetainedParts {
sealed: Vec<Arc<[Arc<SampleView>]>>,
front: usize,
tail: Vec<Arc<SampleView>>,
len: usize,
}
impl RetainedParts {
#[cfg(test)]
pub(crate) fn chunks(&self) -> usize {
self.sealed.len() + 1
}
pub(crate) fn flatten(self) -> Arc<[Arc<SampleView>]> {
let mut out: Vec<Arc<SampleView>> = Vec::with_capacity(self.len);
for (i, chunk) in self.sealed.iter().enumerate() {
let from = if i == 0 { self.front } else { 0 };
out.extend(chunk[from..].iter().cloned());
}
out.extend(self.tail);
Arc::from(out)
}
}
#[derive(Debug)]
pub(crate) struct Retention {
budget: RetentionBudget,
sealed: VecDeque<Arc<[Arc<SampleView>]>>,
front: usize,
tail: VecDeque<Arc<SampleView>>,
len: usize,
bytes: usize,
evicted: u64,
expired: u64,
}
impl Retention {
pub(crate) fn new(budget: RetentionBudget) -> Retention {
Retention {
budget,
sealed: VecDeque::new(),
front: 0,
tail: VecDeque::new(),
len: 0,
bytes: 0,
evicted: 0,
expired: 0,
}
}
pub(crate) fn set_budget(&mut self, budget: RetentionBudget) {
self.budget = budget;
}
pub(crate) fn push(&mut self, view: Arc<SampleView>, now: Instant) {
self.bytes += sample_cost(&view);
self.tail.push_back(view);
self.len += 1;
if self.tail.len() >= CHUNK {
self.sealed.push_back(self.tail.drain(..).collect());
}
self.expire(now);
while self.bytes > self.budget.max_bytes && self.len > 1 {
self.pop_front();
self.evicted += 1;
}
}
fn oldest(&self) -> Option<&Arc<SampleView>> {
match self.sealed.front() {
Some(chunk) => chunk.get(self.front),
None => self.tail.front(),
}
}
fn newest(&self) -> Option<&Arc<SampleView>> {
match self.tail.back() {
Some(view) => Some(view),
None => self.sealed.back().and_then(|chunk| chunk.last()),
}
}
fn pop_front(&mut self) {
let popped = match self.sealed.front() {
Some(chunk) => {
let view = Arc::clone(&chunk[self.front]);
self.front += 1;
if self.front >= chunk.len() {
self.sealed.pop_front();
self.front = 0;
}
Some(view)
}
None => self.tail.pop_front(),
};
if let Some(v) = popped {
self.bytes = self.bytes.saturating_sub(sample_cost(&v));
self.len -= 1;
}
}
fn expire(&mut self, now: Instant) {
while self
.oldest()
.is_some_and(|v| now.saturating_duration_since(v.received) > self.budget.max_age)
{
self.pop_front();
self.expired += 1;
}
}
pub(crate) fn parts(&mut self, now: Instant) -> RetainedParts {
self.expire(now);
RetainedParts {
sealed: self.sealed.iter().map(Arc::clone).collect(),
front: self.front,
tail: self.tail.iter().map(Arc::clone).collect(),
len: self.len,
}
}
#[cfg(test)]
pub(crate) fn snapshot(&mut self, now: Instant) -> Arc<[Arc<SampleView>]> {
self.parts(now).flatten()
}
pub(crate) fn stats(&mut self, now: Instant) -> RetentionStats {
self.expire(now);
let span = match (self.oldest(), self.newest()) {
(Some(oldest), Some(newest)) => {
newest.received.saturating_duration_since(oldest.received)
}
_ => Duration::ZERO,
};
RetentionStats {
budget: self.budget,
retained: self.len,
retained_bytes: self.bytes,
span,
evicted: self.evicted,
expired: self.expired,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zenoh::sample::SampleKind;
fn view(key: &str, len: usize, received: Instant) -> Arc<SampleView> {
Arc::new(SampleView {
key: key.to_string(),
payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
encoding: String::new(),
kind: SampleKind::Put,
timestamp: None,
stamped_by: None,
attachment: None,
priority: zenoh::qos::Priority::DEFAULT,
congestion_control: zenoh::qos::CongestionControl::DEFAULT,
reliability: zenoh::qos::Reliability::DEFAULT,
express: false,
source: None,
received,
})
}
#[test]
fn the_default_budget_is_64_mib_and_two_minutes() {
let b = RetentionBudget::default();
assert_eq!(b.max_bytes, 64 * 1024 * 1024);
assert_eq!(b.max_age, Duration::from_secs(120));
}
#[test]
fn the_byte_budget_evicts_oldest_first_and_counts() {
let now = Instant::now();
let mut r = Retention::new(RetentionBudget {
max_bytes: 3 * sample_cost(&view("k", 100, now)),
max_age: Duration::from_secs(3600),
});
for i in 0..10 {
r.push(view(&format!("k{i}"), 100 - i, now), now);
}
let s = r.stats(now);
assert!(s.retained < 10, "the bound bit");
assert_eq!(s.retained as u64 + s.evicted, 10, "present or counted");
assert_eq!(s.expired, 0, "nothing aged out — the kinds stay apart");
let kept = r.snapshot(now);
assert_eq!(kept.first().unwrap().key, format!("k{}", 10 - kept.len()));
assert_eq!(kept.last().unwrap().key, "k9", "newest survives");
}
#[test]
fn aging_out_is_expiry_not_eviction() {
let t0 = Instant::now();
let mut r = Retention::new(RetentionBudget {
max_bytes: usize::MAX,
max_age: Duration::from_secs(10),
});
r.push(view("old", 4, t0), t0);
r.push(
view("new", 4, t0 + Duration::from_secs(20)),
t0 + Duration::from_secs(20),
);
let s = r.stats(t0 + Duration::from_secs(20));
assert_eq!(s.retained, 1);
assert_eq!(s.expired, 1);
assert_eq!(s.evicted, 0, "no byte bound bit — two kinds, two numbers");
assert_eq!(r.snapshot(t0 + Duration::from_secs(20))[0].key, "new");
}
#[test]
fn one_oversized_sample_is_held_not_hidden() {
let now = Instant::now();
let mut r = Retention::new(RetentionBudget {
max_bytes: 8,
max_age: Duration::from_secs(3600),
});
r.push(view("big", 1024, now), now);
let s = r.stats(now);
assert_eq!(s.retained, 1);
assert!(
s.retained_bytes > s.budget.max_bytes,
"over budget, and said so"
);
}
#[test]
fn the_window_reads_the_same_across_chunk_boundaries() {
let now = Instant::now();
let mut r = Retention::new(RetentionBudget {
max_bytes: usize::MAX,
max_age: Duration::from_secs(3600),
});
let total = CHUNK * 2 + 7;
for i in 0..total {
r.push(view(&format!("k{i:05}"), 8, now), now);
}
let kept = r.snapshot(now);
assert_eq!(kept.len(), total);
assert_eq!(kept[0].key, "k00000");
assert_eq!(kept[CHUNK].key, format!("k{CHUNK:05}"), "the seam holds");
assert_eq!(kept.last().unwrap().key, format!("k{:05}", total - 1));
assert_eq!(r.stats(now).retained, total);
}
#[test]
fn eviction_walks_into_a_sealed_chunk() {
let now = Instant::now();
let keep = CHUNK + 5;
let mut r = Retention::new(RetentionBudget {
max_bytes: sample_cost(&view("k00000", 8, now)) * keep,
max_age: Duration::from_secs(3600),
});
let total = CHUNK * 3;
for i in 0..total {
r.push(view(&format!("k{i:05}"), 8, now), now);
}
let s = r.stats(now);
assert_eq!(s.retained, keep, "the bound bit mid-chunk");
assert_eq!(
s.retained as u64 + s.evicted,
total as u64,
"present or counted"
);
let kept = r.snapshot(now);
assert_eq!(kept.len(), keep);
assert_eq!(kept[0].key, format!("k{:05}", total - keep));
assert_eq!(kept.last().unwrap().key, format!("k{:05}", total - 1));
}
#[test]
fn the_span_is_measured_not_claimed() {
let t0 = Instant::now();
let mut r = Retention::new(RetentionBudget::default());
r.push(view("a", 4, t0), t0);
r.push(
view("b", 4, t0 + Duration::from_secs(30)),
t0 + Duration::from_secs(30),
);
assert_eq!(
r.stats(t0 + Duration::from_secs(30)).span,
Duration::from_secs(30)
);
}
}