Skip to main content

zerodds_dcps/
durability_service.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Durability-service storage backend (Spec §2.2.3.5 + §2.2.3.4
4//! TRANSIENT/PERSISTENT path).
5//!
6//! In DDS the durability levels are characterized as follows (Spec
7//! §2.2.3.4 Tab. 16):
8//! * `VOLATILE`: no history for late joiners.
9//! * `TRANSIENT_LOCAL`: writer history until disconnect.
10//! * `TRANSIENT`: a separate storage service holds the history beyond
11//!   the writer lifetime, but does NOT survive a service crash.
12//! * `PERSISTENT`: like TRANSIENT, but disk-persistent.
13//!
14//! This module implements the backend abstraction for TRANSIENT +
15//! PERSISTENT. The RTPS path feeds it in the writer data path
16//! (`runtime.rs::handle_user_publish`) and consumes it on late-joiner
17//! match (in addition to the writer's own TRANSIENT_LOCAL history).
18//!
19//! Architecture:
20//!
21//! 1. [`DurabilityBackend`] — trait with `store`, `replay_for_topic`,
22//!    `cleanup_after_delay`.
23//! 2. [`InMemoryDurabilityBackend`] — default for the TRANSIENT kind.
24//! 3. [`OnDiskDurabilityBackend`] — for the PERSISTENT kind, persists
25//!    on a directory hierarchy (one file per `(topic, instance,
26//!    sequence)`).
27//!
28//! Both backends respect `DurabilityServiceQosPolicy`:
29//! `service_cleanup_delay` (wait time after `unregister` before the
30//! instance is removed), `history_kind`/`history_depth` (cap per
31//! instance), `max_samples`/`max_instances`/`max_samples_per_instance`.
32
33extern crate alloc;
34
35use alloc::collections::BTreeMap;
36use alloc::string::{String, ToString};
37use alloc::vec::Vec;
38use core::time::Duration as CoreDuration;
39use std::path::PathBuf;
40use std::sync::Mutex;
41use std::time::SystemTime;
42
43use zerodds_qos::DurabilityKind;
44use zerodds_qos::policies::durability_service::DurabilityServiceQosPolicy;
45use zerodds_qos::policies::history::HistoryKind;
46use zerodds_qos::policies::resource_limits::LENGTH_UNLIMITED;
47
48use crate::error::{DdsError, Result};
49
50/// Stable sample slot in the durability history.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DurabilitySample {
53    /// Topic name.
54    pub topic: String,
55    /// Instance KeyHash.
56    pub instance_key: [u8; 16],
57    /// Sequence number (writer-assigned, monotonic).
58    pub sequence: u64,
59    /// Encoded payload (XCDR2 body).
60    pub payload: Vec<u8>,
61    /// Creation timestamp (wall clock).
62    pub created_at: SystemTime,
63}
64
65/// Trait for durability storage backends. Both implementations
66/// (in-memory + on-disk) satisfy the same interface so the runtime
67/// path can swap them.
68pub trait DurabilityBackend: Send + Sync {
69    /// Persists a sample.
70    ///
71    /// # Errors
72    /// `OutOfResources` when the `max_samples` caps are exceeded;
73    /// I/O error for on-disk backends.
74    fn store(&self, sample: DurabilitySample) -> Result<()>;
75
76    /// Returns all stored samples for a topic in order of their
77    /// sequence number (sorted per instance).
78    ///
79    /// # Errors
80    /// I/O error for on-disk backends.
81    fn replay_for_topic(&self, topic: &str) -> Result<Vec<DurabilitySample>>;
82
83    /// Marks an instance as `unregister` so that its associated
84    /// samples are removed after `service_cleanup_delay`. `now` is the
85    /// current wall clock; the actual deletion happens later via
86    /// [`Self::cleanup`].
87    ///
88    /// # Errors
89    /// I/O error for on-disk backends.
90    fn unregister_instance(
91        &self,
92        topic: &str,
93        instance_key: [u8; 16],
94        now: SystemTime,
95    ) -> Result<()>;
96
97    /// Deletes all instances whose `unregister` timestamp + cleanup
98    /// delay is before `now`. Returns the number of removed instances.
99    ///
100    /// # Errors
101    /// I/O error for on-disk backends.
102    fn cleanup(&self, now: SystemTime) -> Result<usize>;
103}
104
105/// Helper: service cleanup delay from QoS into `core::time::Duration`.
106///
107/// QoS Duration stores `fraction` as `2^-32 s`, not as nanos.
108fn cleanup_delay(qos: &DurabilityServiceQosPolicy) -> CoreDuration {
109    let secs = u64::try_from(qos.service_cleanup_delay.seconds.max(0)).unwrap_or(0);
110    // fraction (2^-32 s) → nanoseconds
111    let frac = u64::from(qos.service_cleanup_delay.fraction);
112    let nanos = (frac.saturating_mul(1_000_000_000) >> 32) as u32;
113    CoreDuration::new(secs, nanos)
114}
115
116/// Per-instance slot with ringbuffer-like history.
117#[derive(Debug, Default, Clone)]
118struct InstanceSlot {
119    samples: Vec<DurabilitySample>,
120    /// `unregister` timestamp; when `Some(t)`, the instance is removed
121    /// from the backend after `t + service_cleanup_delay`.
122    unregistered_at: Option<SystemTime>,
123}
124
125impl InstanceSlot {
126    fn push(
127        &mut self,
128        s: DurabilitySample,
129        history_kind: HistoryKind,
130        history_depth: i32,
131        max_samples_per_instance: i32,
132    ) -> Result<()> {
133        // History-kind cap.
134        match history_kind {
135            HistoryKind::KeepLast => {
136                let depth_unsigned = if history_depth <= 0 {
137                    1
138                } else {
139                    history_depth as usize
140                };
141                while self.samples.len() >= depth_unsigned {
142                    self.samples.remove(0);
143                }
144            }
145            HistoryKind::KeepAll => {
146                if max_samples_per_instance != LENGTH_UNLIMITED
147                    && self.samples.len() >= max_samples_per_instance as usize
148                {
149                    return Err(DdsError::OutOfResources {
150                        what: "durability backend: max_samples_per_instance reached",
151                    });
152                }
153            }
154        }
155        self.samples.push(s);
156        Ok(())
157    }
158}
159
160/// `(topic, instance_key)` lookup key.
161type Key = (String, [u8; 16]);
162
163#[derive(Debug, Default)]
164struct InMemoryState {
165    by_key: BTreeMap<Key, InstanceSlot>,
166    total_samples: usize,
167}
168
169/// In-memory durability backend (default for DurabilityKind::Transient).
170pub struct InMemoryDurabilityBackend {
171    qos: DurabilityServiceQosPolicy,
172    state: Mutex<InMemoryState>,
173}
174
175impl InMemoryDurabilityBackend {
176    /// Constructor.
177    #[must_use]
178    pub fn new(qos: DurabilityServiceQosPolicy) -> Self {
179        Self {
180            qos,
181            state: Mutex::new(InMemoryState::default()),
182        }
183    }
184
185    /// Number of stored samples (diagnostics / tests).
186    #[must_use]
187    pub fn len(&self) -> usize {
188        self.state.lock().map(|s| s.total_samples).unwrap_or(0)
189    }
190
191    /// True if no samples are stored.
192    #[must_use]
193    pub fn is_empty(&self) -> bool {
194        self.len() == 0
195    }
196}
197
198impl DurabilityBackend for InMemoryDurabilityBackend {
199    fn store(&self, sample: DurabilitySample) -> Result<()> {
200        let mut g = self
201            .state
202            .lock()
203            .map_err(|_| DdsError::PreconditionNotMet {
204                reason: "in-memory durability backend poisoned",
205            })?;
206        // Cap on max_samples / max_instances.
207        if self.qos.max_samples != LENGTH_UNLIMITED
208            && g.total_samples >= self.qos.max_samples as usize
209        {
210            return Err(DdsError::OutOfResources {
211                what: "durability backend: max_samples reached",
212            });
213        }
214        let key = (sample.topic.clone(), sample.instance_key);
215        let new_instance = !g.by_key.contains_key(&key);
216        if new_instance
217            && self.qos.max_instances != LENGTH_UNLIMITED
218            && g.by_key.len() >= self.qos.max_instances as usize
219        {
220            return Err(DdsError::OutOfResources {
221                what: "durability backend: max_instances reached",
222            });
223        }
224        let slot = g.by_key.entry(key).or_default();
225        let before = slot.samples.len();
226        slot.push(
227            sample,
228            self.qos.history_kind,
229            self.qos.history_depth,
230            self.qos.max_samples_per_instance,
231        )?;
232        let delta = slot.samples.len() as isize - before as isize;
233        g.total_samples = (g.total_samples as isize + delta).max(0) as usize;
234        Ok(())
235    }
236
237    fn replay_for_topic(&self, topic: &str) -> Result<Vec<DurabilitySample>> {
238        let g = self
239            .state
240            .lock()
241            .map_err(|_| DdsError::PreconditionNotMet {
242                reason: "in-memory durability backend poisoned",
243            })?;
244        let mut out = Vec::new();
245        for ((t, _), slot) in g.by_key.iter() {
246            if t == topic {
247                out.extend(slot.samples.iter().cloned());
248            }
249        }
250        out.sort_by_key(|s| (s.instance_key, s.sequence));
251        Ok(out)
252    }
253
254    fn unregister_instance(
255        &self,
256        topic: &str,
257        instance_key: [u8; 16],
258        now: SystemTime,
259    ) -> Result<()> {
260        let mut g = self
261            .state
262            .lock()
263            .map_err(|_| DdsError::PreconditionNotMet {
264                reason: "in-memory durability backend poisoned",
265            })?;
266        if let Some(slot) = g.by_key.get_mut(&(topic.to_string(), instance_key)) {
267            slot.unregistered_at = Some(now);
268        }
269        Ok(())
270    }
271
272    fn cleanup(&self, now: SystemTime) -> Result<usize> {
273        let delay = cleanup_delay(&self.qos);
274        let mut g = self
275            .state
276            .lock()
277            .map_err(|_| DdsError::PreconditionNotMet {
278                reason: "in-memory durability backend poisoned",
279            })?;
280        let to_remove: Vec<Key> = g
281            .by_key
282            .iter()
283            .filter_map(|(k, slot)| {
284                slot.unregistered_at.and_then(|ts| {
285                    let due = ts.checked_add(delay)?;
286                    if now >= due { Some(k.clone()) } else { None }
287                })
288            })
289            .collect();
290        let removed = to_remove.len();
291        for k in to_remove {
292            if let Some(slot) = g.by_key.remove(&k) {
293                g.total_samples = g.total_samples.saturating_sub(slot.samples.len());
294            }
295        }
296        Ok(removed)
297    }
298}
299
300// --------------------- On-Disk-Backend (PERSISTENT) ---------------------
301
302/// On-disk durability backend (DurabilityKind::Persistent).
303///
304/// Layout: `<root>/<topic>/<hex(instance_key)>/<sequence>.bin` holds
305/// the encoded payload; `<root>/<topic>/<hex(instance_key)>/.unregistered`
306/// marks the instance cleanup timestamp (Unix nanos as ASCII).
307pub struct OnDiskDurabilityBackend {
308    qos: DurabilityServiceQosPolicy,
309    root: PathBuf,
310}
311
312impl OnDiskDurabilityBackend {
313    /// Constructor — creates the root path if it does not exist.
314    ///
315    /// # Errors
316    /// Filesystem error while creating the root.
317    pub fn new<P: Into<PathBuf>>(root: P, qos: DurabilityServiceQosPolicy) -> Result<Self> {
318        let root = root.into();
319        std::fs::create_dir_all(&root).map_err(|e| DdsError::PreconditionNotMet {
320            reason: io_static_msg(&e, "durability backend: cannot create root"),
321        })?;
322        Ok(Self { qos, root })
323    }
324
325    fn instance_dir(&self, topic: &str, key: &[u8; 16]) -> PathBuf {
326        let mut p = self.root.join(sanitize_topic(topic));
327        p.push(hex16(key));
328        p
329    }
330
331    fn unregister_marker(&self, topic: &str, key: &[u8; 16]) -> PathBuf {
332        self.instance_dir(topic, key).join(".unregistered")
333    }
334
335    fn count_total_samples(&self) -> Result<usize> {
336        let mut total = 0usize;
337        let topics = match std::fs::read_dir(&self.root) {
338            Ok(d) => d,
339            Err(_) => return Ok(0),
340        };
341        for topic_dir in topics.flatten() {
342            if !topic_dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
343                continue;
344            }
345            let instances = match std::fs::read_dir(topic_dir.path()) {
346                Ok(d) => d,
347                Err(_) => continue,
348            };
349            for inst_dir in instances.flatten() {
350                if !inst_dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
351                    continue;
352                }
353                if let Ok(samples) = std::fs::read_dir(inst_dir.path()) {
354                    for s in samples.flatten() {
355                        if s.file_name() != ".unregistered" {
356                            total += 1;
357                        }
358                    }
359                }
360            }
361        }
362        Ok(total)
363    }
364
365    fn count_instances_for_topic(&self, topic: &str) -> usize {
366        let topic_dir = self.root.join(sanitize_topic(topic));
367        match std::fs::read_dir(&topic_dir) {
368            Ok(d) => d
369                .flatten()
370                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
371                .count(),
372            Err(_) => 0,
373        }
374    }
375}
376
377fn io_static_msg(_e: &std::io::Error, msg: &'static str) -> &'static str {
378    msg
379}
380
381fn sanitize_topic(topic: &str) -> String {
382    // Restrict to [A-Za-z0-9_.-], replacing the rest with '_'.
383    topic
384        .chars()
385        .map(|c| {
386            if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
387                c
388            } else {
389                '_'
390            }
391        })
392        .collect()
393}
394
395fn hex16(b: &[u8; 16]) -> String {
396    let mut s = String::with_capacity(32);
397    for x in b {
398        let hi = (x >> 4) & 0xF;
399        let lo = x & 0xF;
400        s.push(core::char::from_digit(u32::from(hi), 16).unwrap_or('0'));
401        s.push(core::char::from_digit(u32::from(lo), 16).unwrap_or('0'));
402    }
403    s
404}
405
406impl DurabilityBackend for OnDiskDurabilityBackend {
407    fn store(&self, sample: DurabilitySample) -> Result<()> {
408        // Pre-check caps.
409        if self.qos.max_samples != LENGTH_UNLIMITED {
410            let total = self.count_total_samples()?;
411            if total >= self.qos.max_samples as usize {
412                return Err(DdsError::OutOfResources {
413                    what: "durability backend: max_samples reached",
414                });
415            }
416        }
417        let inst_dir = self.instance_dir(&sample.topic, &sample.instance_key);
418        let new_instance = !inst_dir.exists();
419        if new_instance && self.qos.max_instances != LENGTH_UNLIMITED {
420            let count = self.count_instances_for_topic(&sample.topic);
421            if count >= self.qos.max_instances as usize {
422                return Err(DdsError::OutOfResources {
423                    what: "durability backend: max_instances reached",
424                });
425            }
426        }
427        std::fs::create_dir_all(&inst_dir).map_err(|e| DdsError::PreconditionNotMet {
428            reason: io_static_msg(&e, "durability backend: mkdir failed"),
429        })?;
430        // History-kind cap.
431        match self.qos.history_kind {
432            HistoryKind::KeepLast => {
433                let depth = if self.qos.history_depth <= 0 {
434                    1
435                } else {
436                    self.qos.history_depth as usize
437                };
438                let mut existing: Vec<(u64, std::path::PathBuf)> = std::fs::read_dir(&inst_dir)
439                    .map_err(|e| DdsError::PreconditionNotMet {
440                        reason: io_static_msg(&e, "durability backend: readdir failed"),
441                    })?
442                    .flatten()
443                    .filter_map(|e| {
444                        let name = e.file_name().to_string_lossy().to_string();
445                        if name == ".unregistered" {
446                            return None;
447                        }
448                        let stem = name.strip_suffix(".bin")?;
449                        let seq = stem.parse::<u64>().ok()?;
450                        Some((seq, e.path()))
451                    })
452                    .collect();
453                existing.sort_by_key(|(seq, _)| *seq);
454                while existing.len() >= depth {
455                    let (_, p) = existing.remove(0);
456                    let _ = std::fs::remove_file(&p);
457                }
458            }
459            HistoryKind::KeepAll => {
460                if self.qos.max_samples_per_instance != LENGTH_UNLIMITED {
461                    let count = std::fs::read_dir(&inst_dir)
462                        .map_err(|e| DdsError::PreconditionNotMet {
463                            reason: io_static_msg(&e, "durability backend: readdir failed"),
464                        })?
465                        .flatten()
466                        .filter(|e| e.file_name() != ".unregistered")
467                        .count();
468                    if count >= self.qos.max_samples_per_instance as usize {
469                        return Err(DdsError::OutOfResources {
470                            what: "durability backend: max_samples_per_instance reached",
471                        });
472                    }
473                }
474            }
475        }
476        let path = inst_dir.join(alloc::format!("{}.bin", sample.sequence));
477        std::fs::write(&path, &sample.payload).map_err(|e| DdsError::PreconditionNotMet {
478            reason: io_static_msg(&e, "durability backend: write failed"),
479        })?;
480        Ok(())
481    }
482
483    fn replay_for_topic(&self, topic: &str) -> Result<Vec<DurabilitySample>> {
484        let topic_dir = self.root.join(sanitize_topic(topic));
485        let mut out = Vec::new();
486        let dirs = match std::fs::read_dir(&topic_dir) {
487            Ok(d) => d,
488            Err(_) => return Ok(Vec::new()),
489        };
490        for inst_entry in dirs.flatten() {
491            let inst_path = inst_entry.path();
492            let key = match parse_hex16(&inst_entry.file_name().to_string_lossy()) {
493                Some(k) => k,
494                None => continue,
495            };
496            let samples = match std::fs::read_dir(&inst_path) {
497                Ok(s) => s,
498                Err(_) => continue,
499            };
500            for entry in samples.flatten() {
501                let name = entry.file_name().to_string_lossy().to_string();
502                if name == ".unregistered" {
503                    continue;
504                }
505                let Some(stem) = name.strip_suffix(".bin") else {
506                    continue;
507                };
508                let Ok(seq) = stem.parse::<u64>() else {
509                    continue;
510                };
511                let payload =
512                    std::fs::read(entry.path()).map_err(|e| DdsError::PreconditionNotMet {
513                        reason: io_static_msg(&e, "durability backend: read failed"),
514                    })?;
515                let created_at = entry
516                    .metadata()
517                    .and_then(|m| m.modified())
518                    .unwrap_or(SystemTime::UNIX_EPOCH);
519                out.push(DurabilitySample {
520                    topic: topic.to_string(),
521                    instance_key: key,
522                    sequence: seq,
523                    payload,
524                    created_at,
525                });
526            }
527        }
528        out.sort_by_key(|s| (s.instance_key, s.sequence));
529        Ok(out)
530    }
531
532    fn unregister_instance(
533        &self,
534        topic: &str,
535        instance_key: [u8; 16],
536        now: SystemTime,
537    ) -> Result<()> {
538        let dir = self.instance_dir(topic, &instance_key);
539        if !dir.exists() {
540            return Ok(());
541        }
542        let nanos = now
543            .duration_since(SystemTime::UNIX_EPOCH)
544            .unwrap_or_default()
545            .as_nanos();
546        let marker = self.unregister_marker(topic, &instance_key);
547        std::fs::write(&marker, nanos.to_string().as_bytes()).map_err(|e| {
548            DdsError::PreconditionNotMet {
549                reason: io_static_msg(&e, "durability backend: marker write failed"),
550            }
551        })?;
552        Ok(())
553    }
554
555    fn cleanup(&self, now: SystemTime) -> Result<usize> {
556        let delay = cleanup_delay(&self.qos);
557        let mut removed = 0usize;
558        let topics = match std::fs::read_dir(&self.root) {
559            Ok(d) => d,
560            Err(_) => return Ok(0),
561        };
562        for topic_dir in topics.flatten() {
563            if !topic_dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
564                continue;
565            }
566            let instances = match std::fs::read_dir(topic_dir.path()) {
567                Ok(d) => d,
568                Err(_) => continue,
569            };
570            for inst_dir in instances.flatten() {
571                if !inst_dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
572                    continue;
573                }
574                let marker = inst_dir.path().join(".unregistered");
575                let Ok(content) = std::fs::read_to_string(&marker) else {
576                    continue;
577                };
578                let Ok(nanos) = content.trim().parse::<u128>() else {
579                    continue;
580                };
581                let unreg = SystemTime::UNIX_EPOCH + CoreDuration::from_nanos(nanos as u64);
582                let due = unreg.checked_add(delay).unwrap_or(SystemTime::UNIX_EPOCH);
583                if now >= due && std::fs::remove_dir_all(inst_dir.path()).is_ok() {
584                    removed += 1;
585                }
586            }
587        }
588        Ok(removed)
589    }
590}
591
592fn parse_hex16(s: &str) -> Option<[u8; 16]> {
593    if s.len() != 32 {
594        return None;
595    }
596    let mut out = [0u8; 16];
597    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
598        let hi = (chunk[0] as char).to_digit(16)?;
599        let lo = (chunk[1] as char).to_digit(16)?;
600        out[i] = ((hi << 4) | lo) as u8;
601    }
602    Some(out)
603}
604
605/// Factory: creates the appropriate backend for the given durability
606/// level. `root` is only needed for Persistent.
607///
608/// # Errors
609/// Filesystem error during on-disk backend initialization;
610/// `BadParameter` if `kind == Volatile/TransientLocal` (no durability
611/// service needed) or `Persistent` without a `root`.
612pub fn make_backend(
613    kind: DurabilityKind,
614    qos: DurabilityServiceQosPolicy,
615    root: Option<PathBuf>,
616) -> Result<alloc::boxed::Box<dyn DurabilityBackend>> {
617    match kind {
618        DurabilityKind::Volatile | DurabilityKind::TransientLocal => Err(DdsError::BadParameter {
619            what: "durability backend: kind does not need a service",
620        }),
621        DurabilityKind::Transient => {
622            Ok(alloc::boxed::Box::new(InMemoryDurabilityBackend::new(qos)))
623        }
624        DurabilityKind::Persistent => {
625            let root = root.ok_or(DdsError::BadParameter {
626                what: "durability backend: Persistent kind requires root path",
627            })?;
628            Ok(alloc::boxed::Box::new(OnDiskDurabilityBackend::new(
629                root, qos,
630            )?))
631        }
632    }
633}
634
635#[cfg(test)]
636#[allow(clippy::expect_used, clippy::unwrap_used)]
637mod tests {
638    use super::*;
639    use std::time::Duration as StdDuration;
640
641    fn sample(topic: &str, key_byte: u8, seq: u64, payload: &[u8]) -> DurabilitySample {
642        DurabilitySample {
643            topic: topic.to_string(),
644            instance_key: [key_byte; 16],
645            sequence: seq,
646            payload: payload.to_vec(),
647            created_at: SystemTime::now(),
648        }
649    }
650
651    fn keep_all_qos() -> DurabilityServiceQosPolicy {
652        DurabilityServiceQosPolicy {
653            history_kind: HistoryKind::KeepAll,
654            history_depth: -1,
655            ..DurabilityServiceQosPolicy::default()
656        }
657    }
658
659    #[test]
660    fn in_memory_store_and_replay_returns_sorted_samples() {
661        let b = InMemoryDurabilityBackend::new(keep_all_qos());
662        b.store(sample("T", 1, 2, b"b")).unwrap();
663        b.store(sample("T", 1, 1, b"a")).unwrap();
664        b.store(sample("T", 2, 1, b"c")).unwrap();
665        let out = b.replay_for_topic("T").unwrap();
666        assert_eq!(out.len(), 3);
667        // sort by (instance, seq)
668        assert_eq!(out[0].sequence, 1);
669        assert_eq!(out[0].instance_key[0], 1);
670        assert_eq!(out[1].sequence, 2);
671        assert_eq!(out[1].instance_key[0], 1);
672        assert_eq!(out[2].instance_key[0], 2);
673    }
674
675    #[test]
676    fn in_memory_keeplast_caps_history_at_depth() {
677        let qos = DurabilityServiceQosPolicy {
678            history_kind: HistoryKind::KeepLast,
679            history_depth: 2,
680            ..DurabilityServiceQosPolicy::default()
681        };
682        let b = InMemoryDurabilityBackend::new(qos);
683        for i in 1u64..=5 {
684            b.store(sample("T", 1, i, &i.to_le_bytes())).unwrap();
685        }
686        let out = b.replay_for_topic("T").unwrap();
687        // Depth=2 → only the last 2 sequences
688        assert_eq!(out.len(), 2);
689        assert_eq!(out[0].sequence, 4);
690        assert_eq!(out[1].sequence, 5);
691    }
692
693    #[test]
694    fn in_memory_keepall_max_samples_per_instance_returns_oor() {
695        let qos = DurabilityServiceQosPolicy {
696            history_kind: HistoryKind::KeepAll,
697            history_depth: -1,
698            max_samples_per_instance: 2,
699            ..DurabilityServiceQosPolicy::default()
700        };
701        let b = InMemoryDurabilityBackend::new(qos);
702        b.store(sample("T", 1, 1, b"a")).unwrap();
703        b.store(sample("T", 1, 2, b"b")).unwrap();
704        let r = b.store(sample("T", 1, 3, b"c"));
705        assert!(matches!(r, Err(DdsError::OutOfResources { .. })));
706    }
707
708    #[test]
709    fn in_memory_max_samples_globally_returns_oor() {
710        let qos = DurabilityServiceQosPolicy {
711            history_kind: HistoryKind::KeepAll,
712            history_depth: -1,
713            max_samples: 2,
714            ..DurabilityServiceQosPolicy::default()
715        };
716        let b = InMemoryDurabilityBackend::new(qos);
717        b.store(sample("T", 1, 1, b"a")).unwrap();
718        b.store(sample("T", 2, 1, b"b")).unwrap();
719        let r = b.store(sample("T", 3, 1, b"c"));
720        assert!(matches!(r, Err(DdsError::OutOfResources { .. })));
721    }
722
723    #[test]
724    fn in_memory_max_instances_returns_oor() {
725        let qos = DurabilityServiceQosPolicy {
726            history_kind: HistoryKind::KeepAll,
727            history_depth: -1,
728            max_instances: 1,
729            ..DurabilityServiceQosPolicy::default()
730        };
731        let b = InMemoryDurabilityBackend::new(qos);
732        b.store(sample("T", 1, 1, b"a")).unwrap();
733        let r = b.store(sample("T", 2, 1, b"b"));
734        assert!(matches!(r, Err(DdsError::OutOfResources { .. })));
735    }
736
737    #[test]
738    fn in_memory_unregister_then_cleanup_removes_after_delay() {
739        let qos = DurabilityServiceQosPolicy {
740            service_cleanup_delay: zerodds_qos::Duration::from_millis(100),
741            history_kind: HistoryKind::KeepAll,
742            history_depth: -1,
743            ..DurabilityServiceQosPolicy::default()
744        };
745        let b = InMemoryDurabilityBackend::new(qos);
746        let t0 = SystemTime::now();
747        b.store(sample("T", 1, 1, b"a")).unwrap();
748        b.unregister_instance("T", [1u8; 16], t0).unwrap();
749        // Before the delay: cleanup does nothing.
750        assert_eq!(b.cleanup(t0 + StdDuration::from_millis(50)).unwrap(), 0);
751        assert_eq!(b.replay_for_topic("T").unwrap().len(), 1);
752        // After the delay: cleanup removes.
753        assert_eq!(b.cleanup(t0 + StdDuration::from_millis(150)).unwrap(), 1);
754        assert!(b.replay_for_topic("T").unwrap().is_empty());
755    }
756
757    #[test]
758    fn in_memory_replay_filters_by_topic() {
759        let b = InMemoryDurabilityBackend::new(keep_all_qos());
760        b.store(sample("A", 1, 1, b"a1")).unwrap();
761        b.store(sample("B", 1, 1, b"b1")).unwrap();
762        let a = b.replay_for_topic("A").unwrap();
763        let bb = b.replay_for_topic("B").unwrap();
764        assert_eq!(a.len(), 1);
765        assert_eq!(bb.len(), 1);
766        assert_eq!(a[0].topic, "A");
767        assert_eq!(bb[0].topic, "B");
768    }
769
770    #[test]
771    fn in_memory_unknown_topic_returns_empty() {
772        let b = InMemoryDurabilityBackend::new(keep_all_qos());
773        assert!(b.replay_for_topic("nope").unwrap().is_empty());
774    }
775
776    #[test]
777    fn make_backend_rejects_volatile_and_transient_local() {
778        let r1 = make_backend(
779            DurabilityKind::Volatile,
780            DurabilityServiceQosPolicy::default(),
781            None,
782        );
783        let r2 = make_backend(
784            DurabilityKind::TransientLocal,
785            DurabilityServiceQosPolicy::default(),
786            None,
787        );
788        assert!(matches!(r1, Err(DdsError::BadParameter { .. })));
789        assert!(matches!(r2, Err(DdsError::BadParameter { .. })));
790    }
791
792    #[test]
793    fn make_backend_persistent_requires_root() {
794        let r = make_backend(
795            DurabilityKind::Persistent,
796            DurabilityServiceQosPolicy::default(),
797            None,
798        );
799        assert!(matches!(r, Err(DdsError::BadParameter { .. })));
800    }
801
802    #[test]
803    fn make_backend_transient_returns_in_memory() {
804        let b = make_backend(
805            DurabilityKind::Transient,
806            DurabilityServiceQosPolicy::default(),
807            None,
808        )
809        .unwrap();
810        b.store(sample("T", 1, 1, b"a")).unwrap();
811        assert_eq!(b.replay_for_topic("T").unwrap().len(), 1);
812    }
813
814    fn tmp_dir(prefix: &str) -> PathBuf {
815        let mut p = std::env::temp_dir();
816        let nanos = SystemTime::now()
817            .duration_since(SystemTime::UNIX_EPOCH)
818            .unwrap()
819            .as_nanos();
820        p.push(alloc::format!("zerodds-dur-{prefix}-{nanos}"));
821        p
822    }
823
824    #[test]
825    fn on_disk_store_and_replay_roundtrip() {
826        let root = tmp_dir("rt");
827        let b = OnDiskDurabilityBackend::new(&root, keep_all_qos()).unwrap();
828        b.store(sample("PersTopic", 7, 1, b"hello")).unwrap();
829        b.store(sample("PersTopic", 7, 2, b"world")).unwrap();
830        let out = b.replay_for_topic("PersTopic").unwrap();
831        assert_eq!(out.len(), 2);
832        assert_eq!(out[0].sequence, 1);
833        assert_eq!(out[0].payload, b"hello");
834        assert_eq!(out[1].sequence, 2);
835        assert_eq!(out[1].payload, b"world");
836        let _ = std::fs::remove_dir_all(&root);
837    }
838
839    #[test]
840    fn on_disk_keeplast_replaces_old_files() {
841        let root = tmp_dir("kl");
842        let qos = DurabilityServiceQosPolicy {
843            history_kind: HistoryKind::KeepLast,
844            history_depth: 2,
845            ..DurabilityServiceQosPolicy::default()
846        };
847        let b = OnDiskDurabilityBackend::new(&root, qos).unwrap();
848        for i in 1u64..=5 {
849            b.store(sample("T", 1, i, &i.to_le_bytes())).unwrap();
850        }
851        let out = b.replay_for_topic("T").unwrap();
852        assert_eq!(out.len(), 2);
853        assert_eq!(out[0].sequence, 4);
854        assert_eq!(out[1].sequence, 5);
855        let _ = std::fs::remove_dir_all(&root);
856    }
857
858    #[test]
859    fn on_disk_persistent_survives_backend_drop() {
860        let root = tmp_dir("survive");
861        {
862            let b = OnDiskDurabilityBackend::new(&root, keep_all_qos()).unwrap();
863            b.store(sample("Pers", 9, 42, b"alive")).unwrap();
864        } // drop
865        // A new backend with the same root path sees the sample.
866        let b2 = OnDiskDurabilityBackend::new(&root, keep_all_qos()).unwrap();
867        let out = b2.replay_for_topic("Pers").unwrap();
868        assert_eq!(out.len(), 1);
869        assert_eq!(out[0].payload, b"alive");
870        let _ = std::fs::remove_dir_all(&root);
871    }
872
873    #[test]
874    fn on_disk_unregister_and_cleanup_removes_directory() {
875        let root = tmp_dir("cleanup");
876        let qos = DurabilityServiceQosPolicy {
877            service_cleanup_delay: zerodds_qos::Duration::from_millis(50),
878            history_kind: HistoryKind::KeepAll,
879            history_depth: -1,
880            ..DurabilityServiceQosPolicy::default()
881        };
882        let b = OnDiskDurabilityBackend::new(&root, qos).unwrap();
883        let t0 = SystemTime::now();
884        b.store(sample("CT", 5, 1, b"v")).unwrap();
885        b.unregister_instance("CT", [5u8; 16], t0).unwrap();
886        assert_eq!(b.cleanup(t0 + StdDuration::from_millis(10)).unwrap(), 0);
887        assert_eq!(b.cleanup(t0 + StdDuration::from_millis(100)).unwrap(), 1);
888        assert!(b.replay_for_topic("CT").unwrap().is_empty());
889        let _ = std::fs::remove_dir_all(&root);
890    }
891
892    #[test]
893    fn on_disk_max_samples_per_instance_returns_oor() {
894        let root = tmp_dir("oor");
895        let qos = DurabilityServiceQosPolicy {
896            history_kind: HistoryKind::KeepAll,
897            history_depth: -1,
898            max_samples_per_instance: 2,
899            ..DurabilityServiceQosPolicy::default()
900        };
901        let b = OnDiskDurabilityBackend::new(&root, qos).unwrap();
902        b.store(sample("T", 1, 1, b"a")).unwrap();
903        b.store(sample("T", 1, 2, b"b")).unwrap();
904        let r = b.store(sample("T", 1, 3, b"c"));
905        assert!(matches!(r, Err(DdsError::OutOfResources { .. })));
906        let _ = std::fs::remove_dir_all(&root);
907    }
908
909    #[test]
910    fn hex16_roundtrip() {
911        let key = [0xAB, 0xCD, 0xEF, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
912        let h = hex16(&key);
913        assert_eq!(h.len(), 32);
914        assert_eq!(parse_hex16(&h).unwrap(), key);
915    }
916
917    #[test]
918    fn parse_hex16_rejects_wrong_length_and_invalid_chars() {
919        assert!(parse_hex16("abc").is_none());
920        assert!(parse_hex16(&"x".repeat(32)).is_none());
921    }
922
923    #[test]
924    fn sanitize_topic_replaces_path_chars() {
925        assert_eq!(sanitize_topic("Topic/With:Path"), "Topic_With_Path");
926        assert_eq!(sanitize_topic("ok-name.v1"), "ok-name.v1");
927    }
928}