1extern 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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DurabilitySample {
53 pub topic: String,
55 pub instance_key: [u8; 16],
57 pub sequence: u64,
59 pub payload: Vec<u8>,
61 pub created_at: SystemTime,
63}
64
65pub trait DurabilityBackend: Send + Sync {
69 fn store(&self, sample: DurabilitySample) -> Result<()>;
75
76 fn replay_for_topic(&self, topic: &str) -> Result<Vec<DurabilitySample>>;
82
83 fn unregister_instance(
91 &self,
92 topic: &str,
93 instance_key: [u8; 16],
94 now: SystemTime,
95 ) -> Result<()>;
96
97 fn cleanup(&self, now: SystemTime) -> Result<usize>;
103}
104
105fn cleanup_delay(qos: &DurabilityServiceQosPolicy) -> CoreDuration {
109 let secs = u64::try_from(qos.service_cleanup_delay.seconds.max(0)).unwrap_or(0);
110 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#[derive(Debug, Default, Clone)]
118struct InstanceSlot {
119 samples: Vec<DurabilitySample>,
120 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 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
160type Key = (String, [u8; 16]);
162
163#[derive(Debug, Default)]
164struct InMemoryState {
165 by_key: BTreeMap<Key, InstanceSlot>,
166 total_samples: usize,
167}
168
169pub struct InMemoryDurabilityBackend {
171 qos: DurabilityServiceQosPolicy,
172 state: Mutex<InMemoryState>,
173}
174
175impl InMemoryDurabilityBackend {
176 #[must_use]
178 pub fn new(qos: DurabilityServiceQosPolicy) -> Self {
179 Self {
180 qos,
181 state: Mutex::new(InMemoryState::default()),
182 }
183 }
184
185 #[must_use]
187 pub fn len(&self) -> usize {
188 self.state.lock().map(|s| s.total_samples).unwrap_or(0)
189 }
190
191 #[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 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
300pub struct OnDiskDurabilityBackend {
308 qos: DurabilityServiceQosPolicy,
309 root: PathBuf,
310}
311
312impl OnDiskDurabilityBackend {
313 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 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 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 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
605pub 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 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 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 assert_eq!(b.cleanup(t0 + StdDuration::from_millis(50)).unwrap(), 0);
751 assert_eq!(b.replay_for_topic("T").unwrap().len(), 1);
752 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 } 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}