Skip to main content

zerodds_rtps/
history_cache.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! `HistoryCache` — ordered sample storage for reliable writer/reader.
4//!
5//! DDSI-RTPS 2.5 §8.4.8. Both sides (writer + reader) hold their own
6//! cache instance:
7//!
8//! - **Writer cache**: `CacheChange`s stored via `write()`, from which
9//!   re-sends happen on AckNack request. Removes samples only
10//!   once **all** matched readers have confirmed them via AckNack.
11//! - **Reader cache**: received `CacheChange`s in SN order, for
12//!   in-order delivery to the application layer. Can be cleared via
13//!   `remove_up_to` after delivery.
14//!
15//! **History QoS (WP 1.4 T3 follow-up):** the cache is configured via
16//! [`HistoryKind`] — `KeepAll` (hard-bounded, error on
17//! overflow) vs. `KeepLast(depth)` (ring buffer, the oldest sample drops
18//! out on overflow). KeepLast is spec-conformant (§8.7.4) and
19//! decouples writer-cache GC from reader-ACKNACK progress — a
20//! stalled reader thereby no longer prevents other
21//! readers from getting further samples ("per-destination queue" model).
22
23extern crate alloc;
24use alloc::collections::BTreeMap;
25use alloc::sync::Arc;
26use alloc::vec::Vec;
27// portable_atomic instead of core::sync::atomic, so AtomicI64/AtomicU64
28// also work on Cortex-M targets without native 64-bit atomics.
29// On x86_64/aarch64 Linux this is identical to the stdlib.
30use portable_atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
31
32use crate::wire_types::SequenceNumber;
33
34#[cfg(feature = "inspect")]
35use alloc::borrow::ToOwned;
36
37#[cfg(feature = "inspect")]
38fn dispatch_rtps_tap(label: &str, sn: SequenceNumber, payload: Vec<u8>) {
39    let ts_ns = std::time::SystemTime::now()
40        .duration_since(std::time::UNIX_EPOCH)
41        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
42        .unwrap_or(0);
43    #[allow(clippy::cast_sign_loss)]
44    let corr = sn.0 as u64;
45    let frame = zerodds_inspect_endpoint::Frame::rtps(label.to_owned(), ts_ns, corr, payload);
46    zerodds_inspect_endpoint::tap::dispatch(&frame);
47}
48
49/// Kind of a cache entry (DDSI-RTPS §8.2.1.2 / §8.7.2.2.2).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ChangeKind {
52    /// Valid sample, accepted by the DDS DataReader filter.
53    Alive,
54    /// Valid sample, discarded by the reader-side filter (TIME_BASED_FILTER /
55    /// ContentFilteredTopic) — but stays "available" in the NACK
56    /// path, so the reliable writer does not re-send it
57    /// (filteredCount counter in `ChangeFromWriter`).
58    AliveFiltered,
59    /// `dispose` marker.
60    NotAliveDisposed,
61    /// `unregister` marker.
62    NotAliveUnregistered,
63    /// Combined dispose+unregister.
64    NotAliveDisposedUnregistered,
65}
66
67impl ChangeKind {
68    /// Spec §8.4.10.5 — `is_relevant` true for all live kinds; only
69    /// `AliveFiltered` is explicitly *not* relevant for the DDS
70    /// user-API path (but counts as "received" in the NACK path).
71    #[must_use]
72    pub fn is_relevant(self) -> bool {
73        !matches!(self, Self::AliveFiltered)
74    }
75
76    /// Spec §8.4.10.5 — `is_alive_kind` covers Alive + AliveFiltered.
77    #[must_use]
78    pub fn is_alive_kind(self) -> bool {
79        matches!(self, Self::Alive | Self::AliveFiltered)
80    }
81}
82
83/// Single cache entry.
84///
85/// `payload` is held as `Arc<[u8]>` — the cache, the writer build-
86/// datagram path and reader delivery share a single
87/// allocation. This saves the n-fold `Vec::clone()` per reader proxy
88/// in the reliable-writer tick (perf audit F7/F8/F10, ~30-50 %
89/// throughput gain for large payloads).
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CacheChange {
92    /// Sequence number (writer-locally unique).
93    pub sequence_number: SequenceNumber,
94    /// Payload (serialized sample), reference-counted.
95    pub payload: Arc<[u8]>,
96    /// Kind of the event.
97    pub kind: ChangeKind,
98    /// Optional `PID_KEY_HASH` from the inline QoS (Spec §9.6.4.8).
99    /// Reader side: filled for keyed topics + ALIVE samples with an inline hash
100    /// or for lifecycle markers (Disposed/Unregistered)
101    /// by the reader path. Writer side: set by the writer when the path
102    /// propagates the hash along the sample pipeline.
103    pub key_hash: Option<[u8; 16]>,
104    /// Source timestamp of the write (DDSI-RTPS §8.3.7.9 / §8.7.3). When set,
105    /// the writer prepends an INFO_TS submessage before the DATA so a remote
106    /// reader can populate `SampleInfo.source_timestamp` and apply
107    /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP`. `None` ⇒ no INFO_TS (the
108    /// reader falls back to reception order). Held on the change so retransmits
109    /// carry the *original* timestamp, not the resend wall-clock.
110    pub source_timestamp: Option<crate::header_extension::HeTimestamp>,
111}
112
113impl CacheChange {
114    /// Creates an alive change. Takes `Vec<u8>` and
115    /// converts once into `Arc<[u8]>`. For call sites that already have the
116    /// allocation as an Arc, there is [`Self::alive_arc`].
117    #[must_use]
118    pub fn alive(sn: SequenceNumber, payload: Vec<u8>) -> Self {
119        Self::alive_arc(sn, Arc::from(payload))
120    }
121
122    /// Creates an alive change from an already-existing
123    /// `Arc<[u8]>` payload. Zero-copy path for callers that share the
124    /// allocation (writer ↔ cache ↔ datagram).
125    ///
126    /// **Crate-internal:** external users should enter via [`Self::alive`] with
127    /// `Vec<u8>` — the Arc path is a pure writer/reader-
128    /// internal optimization and should not accidentally become part of the
129    /// public API.
130    #[must_use]
131    pub(crate) fn alive_arc(sn: SequenceNumber, payload: Arc<[u8]>) -> Self {
132        Self {
133            sequence_number: sn,
134            payload,
135            kind: ChangeKind::Alive,
136            key_hash: None,
137            source_timestamp: None,
138        }
139    }
140
141    /// Attaches a source timestamp (builder style). The writer emits an INFO_TS
142    /// submessage before the DATA for any change that carries one.
143    #[must_use]
144    pub fn with_source_timestamp(
145        mut self,
146        ts: Option<crate::header_extension::HeTimestamp>,
147    ) -> Self {
148        self.source_timestamp = ts;
149        self
150    }
151
152    /// Creates a lifecycle marker (Spec §8.2.1.2). `payload` is the
153    /// key-only serialization of the disposed/unregistered instance —
154    /// exactly what lands as `PID_KEY_HASH` in the inline QoS.
155    #[must_use]
156    pub fn lifecycle(sn: SequenceNumber, payload: Vec<u8>, kind: ChangeKind) -> Self {
157        Self {
158            sequence_number: sn,
159            payload: Arc::from(payload),
160            kind,
161            key_hash: None,
162            source_timestamp: None,
163        }
164    }
165}
166
167/// History-QoS (DDSI-RTPS §8.7.4, Spec Table 17).
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum HistoryKind {
170    /// The cache is hard-bounded; on overflow `insert` returns a
171    /// `CapacityExceeded` error. Useful for no-loss scenarios in
172    /// which the writer prefers to block rather than discard data
173    /// (file transfer, logging).
174    KeepAll,
175    /// The cache holds at most `depth` newest samples. On overflow the
176    /// **oldest** sample drops out automatically — the writer `insert`
177    /// never fails due to capacity. The spec default for DDS.
178    KeepLast {
179        /// Maximum number of samples in the cache.
180        depth: usize,
181    },
182}
183
184/// Error variants for cache operations.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum CacheError {
188    /// The `KeepAll` cache has reached its capacity.
189    CapacityExceeded,
190    /// The same SN was already inserted.
191    DuplicateSequenceNumber,
192    /// `KeepLast` with `depth == 0` — every insert would be an immediate
193    /// drop. This case is rejected at `insert` instead of silently accepted.
194    ZeroDepth,
195}
196
197/// Sentinel value for "no entry" in the `AtomicI64` stats. Chosen
198/// so that every valid `SequenceNumber.0` (>= 1 per spec)
199/// is uniquely distinguishable as a positive value.
200const STATS_SENTINEL_NO_SN: i64 = i64::MIN;
201
202/// Atomically updated snapshot statistics of a [`HistoryCache`].
203///
204/// **Note:** the actual `BTreeMap` storage of the cache
205/// still needs `&mut self` to mutate (concurrent read/write
206/// `BTreeMap` mutation does not exist in `std`), but **statistics values**
207/// (length, max/min SN, eviction counter) are carried in parallel in an
208/// `Arc<HistoryCacheStats>`. Monitoring threads, SEDP tick
209/// loops and telemetry can thus poll lock-free, without taking the
210/// writer/reader lock.
211///
212/// Consistency guarantee: every mutating method of the cache updates the
213/// atomics **after** the `BTreeMap` mutation, with `Release` ordering.
214/// Readers use `Acquire` ordering — they see a consistent
215/// state of the **last** completed cache operation, never a
216/// half-updated state of the individual atomics.
217///
218/// What is *not* guaranteed: cross-field consistency. If a
219/// reader reads `len` and then `max_sn`, further inserts can have
220/// happened between the loads. That is acceptable for
221/// monitoring; for hard wire paths (heartbeat build) the
222/// writer lock is still taken.
223#[derive(Debug)]
224pub struct HistoryCacheStats {
225    /// Number of changes in the cache (corresponds to `BTreeMap::len`).
226    pub len: AtomicUsize,
227    /// Number of samples discarded by `KeepLast` eviction since start.
228    pub evicted: AtomicU64,
229    /// Highest SN in the cache, or [`STATS_SENTINEL_NO_SN`] if empty.
230    pub max_sn: AtomicI64,
231    /// Lowest SN in the cache, or [`STATS_SENTINEL_NO_SN`] if empty.
232    pub min_sn: AtomicI64,
233}
234
235impl Default for HistoryCacheStats {
236    fn default() -> Self {
237        Self {
238            len: AtomicUsize::new(0),
239            evicted: AtomicU64::new(0),
240            max_sn: AtomicI64::new(STATS_SENTINEL_NO_SN),
241            min_sn: AtomicI64::new(STATS_SENTINEL_NO_SN),
242        }
243    }
244}
245
246impl HistoryCacheStats {
247    /// Snapshot of the four atomics as a plain-old-data struct. Loaded with
248    /// `Acquire` ordering — synchronized with the
249    /// `Release` store in [`HistoryCache::insert`] /
250    /// [`HistoryCache::remove_up_to`].
251    #[must_use]
252    pub fn snapshot(&self) -> HistoryCacheSnapshot {
253        HistoryCacheSnapshot {
254            len: self.len.load(Ordering::Acquire),
255            evicted: self.evicted.load(Ordering::Acquire),
256            max_sn: decode_sn_atom(self.max_sn.load(Ordering::Acquire)),
257            min_sn: decode_sn_atom(self.min_sn.load(Ordering::Acquire)),
258        }
259    }
260}
261
262impl Clone for HistoryCacheStats {
263    fn clone(&self) -> Self {
264        Self {
265            len: AtomicUsize::new(self.len.load(Ordering::Acquire)),
266            evicted: AtomicU64::new(self.evicted.load(Ordering::Acquire)),
267            max_sn: AtomicI64::new(self.max_sn.load(Ordering::Acquire)),
268            min_sn: AtomicI64::new(self.min_sn.load(Ordering::Acquire)),
269        }
270    }
271}
272
273fn decode_sn_atom(v: i64) -> Option<SequenceNumber> {
274    if v == STATS_SENTINEL_NO_SN {
275        None
276    } else {
277        Some(SequenceNumber(v))
278    }
279}
280
281fn encode_sn_atom(sn: Option<SequenceNumber>) -> i64 {
282    sn.map_or(STATS_SENTINEL_NO_SN, |s| s.0)
283}
284
285/// Plain-old-data snapshot of the `HistoryCache` statistics at a
286/// single point in time. Produced by [`HistoryCacheStats::snapshot`];
287/// each component is loaded with `Acquire` ordering.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct HistoryCacheSnapshot {
290    /// Number of changes.
291    pub len: usize,
292    /// Number of samples discarded by `KeepLast` eviction.
293    pub evicted: u64,
294    /// Highest SN, if present.
295    pub max_sn: Option<SequenceNumber>,
296    /// Lowest SN, if present.
297    pub min_sn: Option<SequenceNumber>,
298}
299
300/// Ordered sample storage.
301///
302/// Internal storage: `BTreeMap` for O(log n) insert/lookup and an efficient
303/// range iterator.
304///
305/// **Note:** the writing methods still need
306/// `&mut self` (BTreeMap is not concurrent-safe), but stats are
307/// available in parallel in an `Arc<HistoryCacheStats>` — see
308/// [`stats`](Self::stats).
309#[derive(Debug)]
310pub struct HistoryCache {
311    changes: BTreeMap<SequenceNumber, CacheChange>,
312    kind: HistoryKind,
313    max_samples: usize,
314    evicted_count: u64,
315    stats: Arc<HistoryCacheStats>,
316    /// Optional label for the inspect-endpoint tap (R-026). Set only with
317    /// the cargo feature `inspect`; otherwise phantom.
318    #[cfg(feature = "inspect")]
319    inspect_label: Option<alloc::string::String>,
320}
321
322impl Clone for HistoryCache {
323    fn clone(&self) -> Self {
324        // Each clone gets its **own** `Arc<HistoryCacheStats>`,
325        // so mutations on the clone do not corrupt the stats of the
326        // original. The initial value is taken from the original,
327        // so a Cache.clone() shows an equivalent
328        // snapshot.
329        Self {
330            changes: self.changes.clone(),
331            kind: self.kind,
332            max_samples: self.max_samples,
333            evicted_count: self.evicted_count,
334            stats: Arc::new((*self.stats).clone()),
335            #[cfg(feature = "inspect")]
336            inspect_label: self.inspect_label.clone(),
337        }
338    }
339}
340
341impl HistoryCache {
342    /// Creates a new cache. `max_samples` is the upper bound:
343    /// with `KeepAll` exceeding it leads to `CapacityExceeded`, with
344    /// `KeepLast` to LRU eviction of the oldest sample.
345    #[must_use]
346    pub fn new_with_kind(kind: HistoryKind, max_samples: usize) -> Self {
347        Self {
348            changes: BTreeMap::new(),
349            kind,
350            max_samples,
351            evicted_count: 0,
352            stats: Arc::new(HistoryCacheStats::default()),
353            #[cfg(feature = "inspect")]
354            inspect_label: None,
355        }
356    }
357
358    /// Sets the inspect-endpoint label for the RTPS-layer tap.
359    /// No-op if the `inspect` feature is off.
360    #[cfg(feature = "inspect")]
361    pub fn set_inspect_label(&mut self, label: alloc::string::String) {
362        self.inspect_label = Some(label);
363    }
364
365    /// Shared stats handle for **lock-free** monitoring.
366    ///
367    /// Consumers hold an `Arc<HistoryCacheStats>` and poll the
368    /// atomics with `Acquire` ordering. Values always reflect a
369    /// completed cache mutation; cross-field consistency between
370    /// `len` and `max_sn` is not guaranteed (tear risk).
371    #[must_use]
372    pub fn stats(&self) -> Arc<HistoryCacheStats> {
373        Arc::clone(&self.stats)
374    }
375
376    /// Synchronizes the atomics with the current `BTreeMap` state.
377    /// Called at the end of all mutating methods.
378    fn refresh_stats(&self) {
379        self.stats.len.store(self.changes.len(), Ordering::Release);
380        self.stats
381            .evicted
382            .store(self.evicted_count, Ordering::Release);
383        let max = self.changes.keys().next_back().copied();
384        let min = self.changes.keys().next().copied();
385        self.stats
386            .max_sn
387            .store(encode_sn_atom(max), Ordering::Release);
388        self.stats
389            .min_sn
390            .store(encode_sn_atom(min), Ordering::Release);
391    }
392
393    /// Legacy constructor — creates a `KeepAll` cache with a hard
394    /// capacity limit. For new users prefer [`new_with_kind`].
395    #[must_use]
396    pub fn new(max_samples: usize) -> Self {
397        Self::new_with_kind(HistoryKind::KeepAll, max_samples)
398    }
399
400    /// History kind of this cache.
401    #[must_use]
402    pub fn kind(&self) -> HistoryKind {
403        self.kind
404    }
405
406    /// **Expert-only**: sets the history kind and `max_samples` cap at runtime.
407    ///
408    /// Usage: short-term expansion for a backend replay burst
409    /// (DurabilityService §2.2.3.5), when KeepLast(1) would collapse the
410    /// replay window. The caller must restore the original kind
411    /// afterwards.
412    ///
413    /// This method moves **no** existing samples. If the
414    /// new cap is smaller than the current sample count, the
415    /// existing samples stay visible — the next `insert` then
416    /// evicts by KeepLast rules.
417    pub fn set_kind_and_max(&mut self, kind: HistoryKind, max_samples: usize) {
418        self.kind = kind;
419        self.max_samples = max_samples;
420    }
421
422    /// `max_samples` cap of the cache.
423    #[must_use]
424    pub fn max_samples(&self) -> usize {
425        self.max_samples
426    }
427
428    /// Number of samples discarded by `KeepLast` eviction since
429    /// start.
430    #[must_use]
431    pub fn evicted_count(&self) -> u64 {
432        self.evicted_count
433    }
434
435    /// Inserts a change.
436    ///
437    /// # Errors
438    /// - `CapacityExceeded`: only with `KeepAll`, cache full.
439    /// - `DuplicateSequenceNumber`: SN already present.
440    /// - `ZeroDepth`: `KeepLast { depth: 0 }`.
441    pub fn insert(&mut self, change: CacheChange) -> Result<(), CacheError> {
442        // Backward-compat wrapper — callers that want the evicted sample back for
443        // their payload pool use
444        // `insert_returning_evicted` directly.
445        self.insert_returning_evicted(change).map(|_| ())
446    }
447
448    /// Like [`Self::insert`], but returns the evicted `CacheChange`
449    /// (or `None` if nothing was evicted). Allows the caller to
450    /// recycle the payload `Arc<[u8]>` instead of dropping it — see
451    /// the `ReliableWriter::stage_sample` hot-path pool.
452    ///
453    /// # Errors
454    /// As [`Self::insert`].
455    pub fn insert_returning_evicted(
456        &mut self,
457        change: CacheChange,
458    ) -> Result<Option<CacheChange>, CacheError> {
459        if self.changes.contains_key(&change.sequence_number) {
460            return Err(CacheError::DuplicateSequenceNumber);
461        }
462        let cap = self.effective_max_samples()?;
463        let mut evicted: Option<CacheChange> = None;
464        if self.changes.len() >= cap {
465            match self.kind {
466                HistoryKind::KeepAll => return Err(CacheError::CapacityExceeded),
467                HistoryKind::KeepLast { .. } => {
468                    // Oldest sample out (LRU by SN order).
469                    if let Some((&oldest, _)) = self.changes.iter().next() {
470                        evicted = self.changes.remove(&oldest);
471                        self.evicted_count = self.evicted_count.saturating_add(1);
472                    }
473                }
474            }
475        }
476        #[cfg(feature = "inspect")]
477        let tap_view = self.inspect_label.as_ref().map(|label| {
478            (
479                label.clone(),
480                change.sequence_number,
481                change.payload.to_vec(),
482            )
483        });
484        self.changes.insert(change.sequence_number, change);
485        self.refresh_stats();
486        #[cfg(feature = "inspect")]
487        if let Some((label, sn, payload)) = tap_view {
488            dispatch_rtps_tap(&label, sn, payload);
489        }
490        Ok(evicted)
491    }
492
493    /// Effective max-samples = min(max_samples, depth).
494    fn effective_max_samples(&self) -> Result<usize, CacheError> {
495        match self.kind {
496            HistoryKind::KeepAll => Ok(self.max_samples),
497            HistoryKind::KeepLast { depth } => {
498                if depth == 0 {
499                    return Err(CacheError::ZeroDepth);
500                }
501                Ok(core::cmp::min(depth, self.max_samples))
502            }
503        }
504    }
505
506    /// Fetches a change by SN.
507    #[must_use]
508    pub fn get(&self, sn: SequenceNumber) -> Option<&CacheChange> {
509        self.changes.get(&sn)
510    }
511
512    /// Removes all changes with SN ≤ `sn`.
513    /// Returns the number of removed entries.
514    pub fn remove_up_to(&mut self, sn: SequenceNumber) -> usize {
515        let keep = self.changes.split_off(&SequenceNumber(sn.0 + 1));
516        let removed = self.changes.len();
517        self.changes = keep;
518        self.refresh_stats();
519        removed
520    }
521
522    /// Iterates in SN order over changes in the range `[lo, hi]`
523    /// (both inclusive).
524    pub fn iter_range(
525        &self,
526        lo: SequenceNumber,
527        hi: SequenceNumber,
528    ) -> impl Iterator<Item = &CacheChange> + '_ {
529        self.changes.range(lo..=hi).map(|(_, v)| v)
530    }
531
532    /// Smallest SN in the cache.
533    #[must_use]
534    pub fn min_sn(&self) -> Option<SequenceNumber> {
535        self.changes.keys().next().copied()
536    }
537
538    /// Largest SN in the cache.
539    #[must_use]
540    pub fn max_sn(&self) -> Option<SequenceNumber> {
541        self.changes.keys().next_back().copied()
542    }
543
544    /// Number of changes.
545    #[must_use]
546    pub fn len(&self) -> usize {
547        self.changes.len()
548    }
549
550    /// True if no changes.
551    #[must_use]
552    pub fn is_empty(&self) -> bool {
553        self.changes.is_empty()
554    }
555
556    /// Maximum capacity.
557    #[must_use]
558    pub fn capacity(&self) -> usize {
559        self.max_samples
560    }
561}
562
563// ============================================================================
564// LockFreeReadHistoryCache — // ============================================================================
565
566/// `HistoryCache` variant with a lock-free read path via RCU/copy-on-write.
567///
568/// **Note:** readers (e.g. the SEDP tick, heartbeat build,
569/// resend iteration) see an `Arc`-stable snapshot of the
570/// `BTreeMap` storage; they access it without a further lock touch.
571/// Writers serialize over an internal [`RcuCell`] mutex and
572/// publish copy-on-write.
573///
574/// # Trade-offs
575///
576/// * **Read path**: 1× mutex acquire for the refcount inc + 0 further
577///   locks. Concurrent readers see the same Arc; read iteration is
578///   essentially lock-free.
579/// * **Write path**: O(n) per insert/remove, because the `BTreeMap`
580///   content must be cloned. Acceptable for small caches
581///   (<= 1000 samples). For write-heavy paths keep using [`HistoryCache`].
582/// * **Memory**: active snapshots consume memory until they are released
583///   (reader lifetime). Cache mutations do not invalidate
584///   existing reader snapshots.
585///
586/// # When to use
587///
588/// * Discovery caches that are read frequently by the SEDP tick + match loops,
589///   but mutated only on `announce_publication`/`subscription`.
590/// * Monitoring/tooling paths that want to iterate over the cache
591///   without a lock touch.
592///
593/// # When NOT to use
594///
595/// * Reliable-writer cache with a high insert rate (every insert clones
596///   the whole BTreeMap). There [`HistoryCache`] stays better.
597///
598/// Persistent data structures (`im::OrdMap`) would push the
599/// write-cost effort to O(log n) — that is the
600/// natural follow-up optimization once this variant lands in production.
601#[cfg(feature = "std")]
602#[derive(Debug)]
603pub struct LockFreeReadHistoryCache {
604    inner: zerodds_foundation::rcu::RcuCell<LockFreeInner>,
605    stats: Arc<HistoryCacheStats>,
606}
607
608/// Inner state of the [`LockFreeReadHistoryCache`]. Handed out by
609/// [`LockFreeReadHistoryCache::snapshot`] as an `Arc<LockFreeInner>`
610/// — readers iterate over `changes` directly.
611#[cfg(feature = "std")]
612#[derive(Debug, Clone)]
613pub struct LockFreeInner {
614    /// Sample-Map keyed by SequenceNumber.
615    pub changes: BTreeMap<SequenceNumber, CacheChange>,
616    /// History QoS kind.
617    pub kind: HistoryKind,
618    /// Cap from QoS.
619    pub max_samples: usize,
620    /// Eviction counter.
621    pub evicted_count: u64,
622}
623
624#[cfg(feature = "std")]
625impl LockFreeInner {
626    fn effective_max_samples(&self) -> Result<usize, CacheError> {
627        match self.kind {
628            HistoryKind::KeepAll => Ok(self.max_samples),
629            HistoryKind::KeepLast { depth } => {
630                if depth == 0 {
631                    return Err(CacheError::ZeroDepth);
632                }
633                Ok(core::cmp::min(depth, self.max_samples))
634            }
635        }
636    }
637}
638
639#[cfg(feature = "std")]
640impl LockFreeReadHistoryCache {
641    /// Creates a new lock-free read cache.
642    #[must_use]
643    pub fn new_with_kind(kind: HistoryKind, max_samples: usize) -> Self {
644        Self {
645            inner: zerodds_foundation::rcu::RcuCell::new(LockFreeInner {
646                changes: BTreeMap::new(),
647                kind,
648                max_samples,
649                evicted_count: 0,
650            }),
651            stats: Arc::new(HistoryCacheStats::default()),
652        }
653    }
654
655    /// Legacy constructor — `KeepAll`.
656    #[must_use]
657    pub fn new(max_samples: usize) -> Self {
658        Self::new_with_kind(HistoryKind::KeepAll, max_samples)
659    }
660
661    /// Lock-free read snapshot of stats.
662    #[must_use]
663    pub fn stats(&self) -> Arc<HistoryCacheStats> {
664        Arc::clone(&self.stats)
665    }
666
667    /// Returns an `Arc` snapshot of the current cache state for
668    /// lock-free iteration.
669    #[must_use]
670    pub fn snapshot(&self) -> Arc<LockFreeInner> {
671        self.inner.read()
672    }
673
674    /// History kind.
675    #[must_use]
676    pub fn kind(&self) -> HistoryKind {
677        self.inner.read().kind
678    }
679
680    /// Number of samples discarded by `KeepLast` eviction.
681    #[must_use]
682    pub fn evicted_count(&self) -> u64 {
683        self.stats.evicted.load(Ordering::Acquire)
684    }
685
686    /// Number of changes (Acquire load of the atomic).
687    #[must_use]
688    pub fn len(&self) -> usize {
689        self.stats.len.load(Ordering::Acquire)
690    }
691
692    /// True if no changes.
693    #[must_use]
694    pub fn is_empty(&self) -> bool {
695        self.len() == 0
696    }
697
698    /// Smallest SN from the atom — lock-free.
699    #[must_use]
700    pub fn min_sn(&self) -> Option<SequenceNumber> {
701        decode_sn_atom(self.stats.min_sn.load(Ordering::Acquire))
702    }
703
704    /// Largest SN from the atom — lock-free.
705    #[must_use]
706    pub fn max_sn(&self) -> Option<SequenceNumber> {
707        decode_sn_atom(self.stats.max_sn.load(Ordering::Acquire))
708    }
709
710    /// Maximum capacity.
711    #[must_use]
712    pub fn capacity(&self) -> usize {
713        self.inner.read().max_samples
714    }
715
716    /// Fetches a change by SN — cloned (CacheChange is Arc-payload-
717    /// wrapped, so a refcount inc).
718    #[must_use]
719    pub fn get(&self, sn: SequenceNumber) -> Option<CacheChange> {
720        self.inner.read().changes.get(&sn).cloned()
721    }
722
723    /// Sample snapshot in the SN range `[lo, hi]`. Returns a Vec
724    /// — we cannot return an Iter `<'a>` over an Arc snapshot
725    /// when the snapshot is not referenced.
726    #[must_use]
727    pub fn iter_range_snapshot(&self, lo: SequenceNumber, hi: SequenceNumber) -> Vec<CacheChange> {
728        let snap = self.inner.read();
729        snap.changes
730            .range(lo..=hi)
731            .map(|(_, v)| v.clone())
732            .collect()
733    }
734
735    /// Inserts a change. Copy-on-write of the BTreeMap.
736    ///
737    /// # Errors
738    /// As [`HistoryCache::insert`].
739    pub fn insert(&self, change: CacheChange) -> Result<(), CacheError> {
740        // Pre-check: avoid the BTreeMap clone if we already know
741        // the insert fails (Duplicate, ZeroDepth, KeepAll-full).
742        let dup_or_full: Result<(), CacheError> = {
743            let snap = self.inner.read();
744            if snap.changes.contains_key(&change.sequence_number) {
745                Err(CacheError::DuplicateSequenceNumber)
746            } else {
747                let cap = snap.effective_max_samples()?;
748                if snap.changes.len() >= cap {
749                    if matches!(snap.kind, HistoryKind::KeepAll) {
750                        Err(CacheError::CapacityExceeded)
751                    } else {
752                        Ok(()) // KeepLast: evict in the write-with
753                    }
754                } else {
755                    Ok(())
756                }
757            }
758        };
759        dup_or_full?;
760        self.inner.modify(|inner| {
761            // Eviction logic: KeepLast drops the oldest.
762            let cap = match inner.effective_max_samples() {
763                Ok(c) => c,
764                Err(_) => return,
765            };
766            if inner.changes.len() >= cap {
767                if let HistoryKind::KeepLast { .. } = inner.kind {
768                    if let Some((&oldest, _)) = inner.changes.iter().next() {
769                        inner.changes.remove(&oldest);
770                        inner.evicted_count = inner.evicted_count.saturating_add(1);
771                    }
772                }
773            }
774            inner.changes.insert(change.sequence_number, change.clone());
775        });
776        self.refresh_stats();
777        Ok(())
778    }
779
780    /// Removes all changes with SN ≤ `sn`. Returns the number removed.
781    pub fn remove_up_to(&self, sn: SequenceNumber) -> usize {
782        let mut removed = 0;
783        self.inner.modify(|inner| {
784            let keep = inner.changes.split_off(&SequenceNumber(sn.0 + 1));
785            removed = inner.changes.len();
786            inner.changes = keep;
787        });
788        self.refresh_stats();
789        removed
790    }
791
792    fn refresh_stats(&self) {
793        let snap = self.inner.read();
794        self.stats.len.store(snap.changes.len(), Ordering::Release);
795        self.stats
796            .evicted
797            .store(snap.evicted_count, Ordering::Release);
798        let max = snap.changes.keys().next_back().copied();
799        let min = snap.changes.keys().next().copied();
800        self.stats
801            .max_sn
802            .store(encode_sn_atom(max), Ordering::Release);
803        self.stats
804            .min_sn
805            .store(encode_sn_atom(min), Ordering::Release);
806    }
807}
808
809#[cfg(test)]
810#[allow(clippy::expect_used, clippy::unwrap_used)]
811mod tests {
812    use super::*;
813
814    fn sn(n: i64) -> SequenceNumber {
815        SequenceNumber(n)
816    }
817
818    fn alive(n: i64) -> CacheChange {
819        CacheChange::alive(sn(n), alloc::vec![n as u8])
820    }
821
822    #[test]
823    fn new_cache_is_empty() {
824        let c = HistoryCache::new(10);
825        assert_eq!(c.len(), 0);
826        assert!(c.is_empty());
827        assert_eq!(c.min_sn(), None);
828        assert_eq!(c.max_sn(), None);
829    }
830
831    #[test]
832    fn insert_and_get() {
833        let mut c = HistoryCache::new(10);
834        c.insert(alive(1)).expect("insert");
835        c.insert(alive(2)).expect("insert");
836        assert_eq!(
837            c.get(sn(1)).map(|ch| ch.payload.as_ref().to_vec()),
838            Some(alloc::vec![1])
839        );
840        assert_eq!(c.get(sn(3)), None);
841        assert_eq!(c.len(), 2);
842    }
843
844    #[test]
845    fn insert_duplicate_is_err() {
846        let mut c = HistoryCache::new(10);
847        c.insert(alive(1)).expect("insert");
848        assert_eq!(c.insert(alive(1)), Err(CacheError::DuplicateSequenceNumber));
849    }
850
851    #[test]
852    fn insert_at_capacity_is_err() {
853        let mut c = HistoryCache::new(2);
854        c.insert(alive(1)).expect("insert");
855        c.insert(alive(2)).expect("insert");
856        assert_eq!(c.insert(alive(3)), Err(CacheError::CapacityExceeded));
857    }
858
859    #[test]
860    fn min_max_sn_reflect_content() {
861        let mut c = HistoryCache::new(10);
862        c.insert(alive(5)).unwrap();
863        c.insert(alive(3)).unwrap();
864        c.insert(alive(7)).unwrap();
865        assert_eq!(c.min_sn(), Some(sn(3)));
866        assert_eq!(c.max_sn(), Some(sn(7)));
867    }
868
869    #[test]
870    fn remove_up_to_inclusive() {
871        let mut c = HistoryCache::new(10);
872        for i in 1..=5 {
873            c.insert(alive(i)).unwrap();
874        }
875        let removed = c.remove_up_to(sn(3));
876        assert_eq!(removed, 3);
877        assert_eq!(c.len(), 2);
878        assert_eq!(c.min_sn(), Some(sn(4)));
879    }
880
881    #[test]
882    fn remove_up_to_with_no_matches_is_noop() {
883        let mut c = HistoryCache::new(10);
884        c.insert(alive(10)).unwrap();
885        assert_eq!(c.remove_up_to(sn(5)), 0);
886        assert_eq!(c.len(), 1);
887    }
888
889    #[test]
890    fn iter_range_is_ordered() {
891        let mut c = HistoryCache::new(10);
892        for i in [5, 1, 3, 8, 2] {
893            c.insert(alive(i)).unwrap();
894        }
895        let collected: alloc::vec::Vec<i64> = c
896            .iter_range(sn(2), sn(5))
897            .map(|ch| ch.sequence_number.0)
898            .collect();
899        assert_eq!(collected, alloc::vec![2, 3, 5]);
900    }
901
902    #[test]
903    fn iter_range_empty_when_no_overlap() {
904        let mut c = HistoryCache::new(10);
905        c.insert(alive(1)).unwrap();
906        c.insert(alive(2)).unwrap();
907        assert_eq!(c.iter_range(sn(10), sn(20)).count(), 0);
908    }
909
910    #[test]
911    fn capacity_accessor() {
912        let c = HistoryCache::new(42);
913        assert_eq!(c.capacity(), 42);
914    }
915
916    #[test]
917    fn cache_change_alive_constructor() {
918        let ch = CacheChange::alive(sn(1), alloc::vec![1, 2, 3]);
919        assert_eq!(ch.kind, ChangeKind::Alive);
920        assert_eq!(ch.sequence_number, sn(1));
921        assert_eq!(ch.payload.as_ref(), &[1, 2, 3][..]);
922    }
923
924    // ---- §8.2.1.2 ChangeKind_t all 4 spec variants + AliveFiltered ----
925
926    #[test]
927    fn change_kind_alive_is_relevant_and_alive() {
928        assert!(ChangeKind::Alive.is_relevant());
929        assert!(ChangeKind::Alive.is_alive_kind());
930    }
931
932    #[test]
933    fn change_kind_alive_filtered_is_alive_but_not_relevant() {
934        // Spec §8.4.10.5: AliveFiltered is alive_kind but !is_relevant.
935        assert!(ChangeKind::AliveFiltered.is_alive_kind());
936        assert!(!ChangeKind::AliveFiltered.is_relevant());
937    }
938
939    #[test]
940    fn change_kind_not_alive_kinds_are_not_alive() {
941        for k in [
942            ChangeKind::NotAliveDisposed,
943            ChangeKind::NotAliveUnregistered,
944            ChangeKind::NotAliveDisposedUnregistered,
945        ] {
946            assert!(!k.is_alive_kind(), "{k:?}");
947            assert!(k.is_relevant(), "{k:?}");
948        }
949    }
950
951    #[test]
952    fn change_kind_distinct_variants() {
953        // Identity sanity — all 5 variants are distinct.
954        let v = [
955            ChangeKind::Alive,
956            ChangeKind::AliveFiltered,
957            ChangeKind::NotAliveDisposed,
958            ChangeKind::NotAliveUnregistered,
959            ChangeKind::NotAliveDisposedUnregistered,
960        ];
961        for (i, a) in v.iter().enumerate() {
962            for (j, b) in v.iter().enumerate() {
963                if i == j {
964                    assert_eq!(a, b);
965                } else {
966                    assert_ne!(a, b);
967                }
968            }
969        }
970    }
971
972    // ========================================================================
973    // D.4 Phase A — Atomic Stats / Lock-Free Snapshot Tests
974    // ========================================================================
975
976    #[test]
977    fn stats_default_is_empty_with_no_sn() {
978        let c = HistoryCache::new(10);
979        let snap = c.stats().snapshot();
980        assert_eq!(snap.len, 0);
981        assert_eq!(snap.evicted, 0);
982        assert_eq!(snap.max_sn, None);
983        assert_eq!(snap.min_sn, None);
984    }
985
986    #[test]
987    fn stats_track_insert_and_remove() {
988        let mut c = HistoryCache::new(10);
989        c.insert(alive(3)).unwrap();
990        c.insert(alive(5)).unwrap();
991        c.insert(alive(7)).unwrap();
992        let snap = c.stats().snapshot();
993        assert_eq!(snap.len, 3);
994        assert_eq!(snap.min_sn, Some(sn(3)));
995        assert_eq!(snap.max_sn, Some(sn(7)));
996        assert_eq!(snap.evicted, 0);
997
998        c.remove_up_to(sn(5));
999        let snap = c.stats().snapshot();
1000        assert_eq!(snap.len, 1);
1001        assert_eq!(snap.min_sn, Some(sn(7)));
1002        assert_eq!(snap.max_sn, Some(sn(7)));
1003    }
1004
1005    #[test]
1006    fn stats_track_keeplast_eviction() {
1007        let mut c = HistoryCache::new_with_kind(HistoryKind::KeepLast { depth: 2 }, 100);
1008        c.insert(alive(1)).unwrap();
1009        c.insert(alive(2)).unwrap();
1010        c.insert(alive(3)).unwrap(); // evicts alive(1)
1011        let snap = c.stats().snapshot();
1012        assert_eq!(snap.len, 2);
1013        assert_eq!(snap.evicted, 1);
1014        assert_eq!(snap.min_sn, Some(sn(2)));
1015        assert_eq!(snap.max_sn, Some(sn(3)));
1016    }
1017
1018    #[test]
1019    fn stats_arc_is_shared_across_clones_of_handle() {
1020        // Multiple `cache.stats()` calls return the same Arc — so that
1021        // a reader that pulls the handle once sees all subsequent
1022        // cache mutations.
1023        let mut c = HistoryCache::new(10);
1024        let s1 = c.stats();
1025        let s2 = c.stats();
1026        assert!(Arc::ptr_eq(&s1, &s2));
1027        c.insert(alive(1)).unwrap();
1028        assert_eq!(s1.snapshot().len, 1);
1029        assert_eq!(s2.snapshot().len, 1);
1030    }
1031
1032    #[test]
1033    fn stats_reader_thread_sees_inserts_concurrently() {
1034        // Lock-free read from a second thread while the writer
1035        // mutated. Correctness test for the Acquire/Release ordering.
1036        use std::sync::Arc as StdArc;
1037        use std::sync::Mutex as StdMutex;
1038        use std::thread;
1039        use std::time::Duration;
1040
1041        let cache = StdArc::new(StdMutex::new(HistoryCache::new(2_000)));
1042        let stats = cache.lock().expect("init lock").stats();
1043
1044        let writer_cache = StdArc::clone(&cache);
1045        let writer = thread::spawn(move || {
1046            for i in 1..=1_000 {
1047                let mut c = writer_cache.lock().expect("write lock");
1048                c.insert(alive(i)).expect("insert");
1049            }
1050        });
1051
1052        let reader_stats = StdArc::clone(&stats);
1053        let reader = thread::spawn(move || {
1054            // Read stats 100x without taking the writer lock.
1055            for _ in 0..100 {
1056                let snap = reader_stats.snapshot();
1057                // len may only grow monotonically while the writer runs.
1058                assert!(snap.len <= 1_000);
1059                if let Some(max) = snap.max_sn {
1060                    assert!(max.0 >= 1 && max.0 <= 1_000);
1061                }
1062                thread::sleep(Duration::from_micros(50));
1063            }
1064        });
1065
1066        writer.join().expect("writer joined");
1067        reader.join().expect("reader joined");
1068
1069        let final_snap = stats.snapshot();
1070        assert_eq!(final_snap.len, 1_000);
1071        assert_eq!(final_snap.max_sn, Some(sn(1_000)));
1072        assert_eq!(final_snap.min_sn, Some(sn(1)));
1073    }
1074
1075    #[test]
1076    fn clone_creates_independent_stats_handles() {
1077        // Cache.clone() must not share the stats Arc of the original,
1078        // otherwise mutations on the clone would corrupt the original.
1079        let mut a = HistoryCache::new(10);
1080        a.insert(alive(1)).unwrap();
1081        let b = a.clone();
1082        assert!(!Arc::ptr_eq(&a.stats(), &b.stats()));
1083        assert_eq!(a.stats().snapshot().len, 1);
1084        assert_eq!(b.stats().snapshot().len, 1);
1085
1086        let mut a_mut = a;
1087        a_mut.insert(alive(2)).unwrap();
1088        assert_eq!(a_mut.stats().snapshot().len, 2);
1089        assert_eq!(b.stats().snapshot().len, 1, "clone unaffected");
1090    }
1091
1092    // ========================================================================
1093    // D.4 Phase C — LockFreeReadHistoryCache Tests
1094    // ========================================================================
1095
1096    #[cfg(feature = "std")]
1097    mod lock_free_tests {
1098        use super::*;
1099
1100        #[test]
1101        fn lock_free_new_is_empty() {
1102            let c = LockFreeReadHistoryCache::new(10);
1103            assert_eq!(c.len(), 0);
1104            assert!(c.is_empty());
1105            assert_eq!(c.min_sn(), None);
1106            assert_eq!(c.max_sn(), None);
1107        }
1108
1109        #[test]
1110        fn lock_free_insert_and_get() {
1111            let c = LockFreeReadHistoryCache::new(10);
1112            // Insert without &mut self — pure interior mutability.
1113            c.insert(alive(1)).unwrap();
1114            c.insert(alive(2)).unwrap();
1115            assert_eq!(
1116                c.get(sn(1)).map(|ch| ch.payload.as_ref().to_vec()),
1117                Some(alloc::vec![1])
1118            );
1119            assert_eq!(c.get(sn(3)), None);
1120            assert_eq!(c.len(), 2);
1121        }
1122
1123        #[test]
1124        fn lock_free_min_max_lock_free_loads() {
1125            let c = LockFreeReadHistoryCache::new(10);
1126            c.insert(alive(5)).unwrap();
1127            c.insert(alive(3)).unwrap();
1128            c.insert(alive(7)).unwrap();
1129            assert_eq!(c.min_sn(), Some(sn(3)));
1130            assert_eq!(c.max_sn(), Some(sn(7)));
1131        }
1132
1133        #[test]
1134        fn lock_free_keeplast_evicts_oldest() {
1135            let c =
1136                LockFreeReadHistoryCache::new_with_kind(HistoryKind::KeepLast { depth: 2 }, 100);
1137            c.insert(alive(1)).unwrap();
1138            c.insert(alive(2)).unwrap();
1139            c.insert(alive(3)).unwrap(); // evicts 1
1140            assert_eq!(c.len(), 2);
1141            assert_eq!(c.min_sn(), Some(sn(2)));
1142            assert_eq!(c.max_sn(), Some(sn(3)));
1143            assert_eq!(c.evicted_count(), 1);
1144        }
1145
1146        #[test]
1147        fn lock_free_keepall_full_rejects() {
1148            let c = LockFreeReadHistoryCache::new(2);
1149            c.insert(alive(1)).unwrap();
1150            c.insert(alive(2)).unwrap();
1151            assert_eq!(c.insert(alive(3)), Err(CacheError::CapacityExceeded));
1152        }
1153
1154        #[test]
1155        fn lock_free_duplicate_sn_rejected() {
1156            let c = LockFreeReadHistoryCache::new(10);
1157            c.insert(alive(1)).unwrap();
1158            assert_eq!(c.insert(alive(1)), Err(CacheError::DuplicateSequenceNumber));
1159        }
1160
1161        #[test]
1162        fn lock_free_remove_up_to() {
1163            let c = LockFreeReadHistoryCache::new(10);
1164            for i in 1..=5 {
1165                c.insert(alive(i)).unwrap();
1166            }
1167            let removed = c.remove_up_to(sn(3));
1168            assert_eq!(removed, 3);
1169            assert_eq!(c.len(), 2);
1170            assert_eq!(c.min_sn(), Some(sn(4)));
1171        }
1172
1173        #[test]
1174        fn lock_free_iter_range_snapshot() {
1175            let c = LockFreeReadHistoryCache::new(10);
1176            for i in 1..=5 {
1177                c.insert(alive(i)).unwrap();
1178            }
1179            let mid: alloc::vec::Vec<_> = c
1180                .iter_range_snapshot(sn(2), sn(4))
1181                .iter()
1182                .map(|ch| ch.sequence_number)
1183                .collect();
1184            assert_eq!(mid, alloc::vec![sn(2), sn(3), sn(4)]);
1185        }
1186
1187        #[test]
1188        fn lock_free_snapshot_outlives_writes() {
1189            // Snapshot API guarantee: a reader Arc stays unchanged,
1190            // even if the writer mutates the cache later.
1191            let c = LockFreeReadHistoryCache::new(10);
1192            c.insert(alive(1)).unwrap();
1193            let snap = c.snapshot();
1194            assert_eq!(snap.changes.len(), 1);
1195
1196            c.insert(alive(2)).unwrap();
1197            c.insert(alive(3)).unwrap();
1198            // Original snapshot: still only SN 1.
1199            assert_eq!(snap.changes.len(), 1);
1200            assert!(snap.changes.contains_key(&sn(1)));
1201            // Live cache: 3 entries.
1202            assert_eq!(c.len(), 3);
1203        }
1204
1205        #[test]
1206        fn lock_free_concurrent_readers_writers_smoke() {
1207            use std::sync::Arc as StdArc;
1208            use std::thread;
1209
1210            let cache: StdArc<LockFreeReadHistoryCache> =
1211                StdArc::new(LockFreeReadHistoryCache::new(2_000));
1212            let cache_w = StdArc::clone(&cache);
1213            let writer = thread::spawn(move || {
1214                for i in 1..=500 {
1215                    cache_w.insert(alive(i)).expect("insert");
1216                }
1217            });
1218
1219            let cache_r = StdArc::clone(&cache);
1220            let reader = thread::spawn(move || {
1221                for _ in 0..200 {
1222                    let snap = cache_r.snapshot();
1223                    // The snapshot is internally consistent: changes.len matches
1224                    // the range between min and max.
1225                    if let (Some(min), Some(max)) = (
1226                        snap.changes.keys().next().copied(),
1227                        snap.changes.keys().next_back().copied(),
1228                    ) {
1229                        let inferred_count = (max.0 - min.0 + 1) as usize;
1230                        assert!(
1231                            snap.changes.len() <= inferred_count,
1232                            "snapshot inkonsistent"
1233                        );
1234                    }
1235                }
1236            });
1237
1238            writer.join().expect("writer joined");
1239            reader.join().expect("reader joined");
1240
1241            assert_eq!(cache.len(), 500);
1242            assert_eq!(cache.max_sn(), Some(sn(500)));
1243        }
1244    }
1245}