zerodds_durability_service/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Standalone DDS Durability-Service daemon (ADR 0009).
5//!
6//! Crate `zerodds-durability-service`. Safety classification: **STANDARD**.
7//!
8//! From an RTPS point of view the service is just another participant. For
9//! each served topic it:
10//!
11//! 1. **Ingests** via a `RELIABLE / TRANSIENT_LOCAL / KEEP_ALL` reader — it
12//! receives every sample the application writers publish (and, thanks to
13//! `TRANSIENT_LOCAL`, the writer history if it joins late).
14//! 2. **Stores** each sample in a [`DurabilityStore`] (sqlite/file/in-memory).
15//! 3. **Replays** via a `TRANSIENT_LOCAL / KEEP_ALL` writer — a late-joining
16//! reader matches it and the standard RTPS path delivers the history. This
17//! keeps working after the original writer's process has died.
18//! 4. **Startup-sync**: on [`serve`](DurabilityService::serve) the replay
19//! writer is primed from the store, so `PERSISTENT` history survives a full
20//! service restart with no application writer present.
21//!
22//! ## Scope (increment 1)
23//! Unkeyed topics (`RawBytes` = single instance). The replay writer assigns
24//! its own sequence numbers — a durability service is a new logical source, so
25//! the original writer's sequence numbers need not be preserved. Per-instance
26//! history for KEYED topics + exact sequence preservation need the per-sample
27//! KeyHash/sequence exposed on the reader API and are a documented follow-up.
28
29#![forbid(unsafe_code)]
30
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
33use std::sync::{Mutex, MutexGuard, mpsc};
34use std::thread::JoinHandle;
35use std::time::{Duration, SystemTime};
36
37use zerodds_dcps::runtime::{UserReaderConfig, UserSample, UserWriterConfig};
38use zerodds_dcps::{
39 DataReader, DataReaderQos, DataWriter, DataWriterQos, DomainParticipant,
40 DomainParticipantFactory, DomainParticipantQos, Publisher, PublisherQos, RawBytes, Subscriber,
41 SubscriberQos, Topic, TopicQos,
42};
43use zerodds_durability_store::{Contract, DurabilitySample, DurabilityStore};
44use zerodds_qos::policies::durability::DurabilityKind;
45use zerodds_qos::policies::history::HistoryKind;
46use zerodds_qos::policies::reliability::ReliabilityKind;
47use zerodds_qos::policies::resource_limits::LENGTH_UNLIMITED;
48use zerodds_qos::{
49 DeadlineQosPolicy, LifespanQosPolicy, LivelinessKind, LivelinessQosPolicy, OwnershipKind,
50};
51
52/// Errors from the durability daemon.
53#[derive(Debug)]
54pub enum ServiceError {
55 /// A DCPS/RTPS operation failed.
56 Dcps(zerodds_dcps::DdsError),
57 /// A storage operation failed.
58 Store(zerodds_durability_store::StoreError),
59 /// An internal lock was poisoned.
60 Poisoned(&'static str),
61}
62
63impl core::fmt::Display for ServiceError {
64 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65 match self {
66 Self::Dcps(e) => write!(f, "durability service: dcps: {e}"),
67 Self::Store(e) => write!(f, "durability service: store: {e}"),
68 Self::Poisoned(w) => write!(f, "durability service: poisoned: {w}"),
69 }
70 }
71}
72impl core::error::Error for ServiceError {}
73impl From<zerodds_dcps::DdsError> for ServiceError {
74 fn from(e: zerodds_dcps::DdsError) -> Self {
75 Self::Dcps(e)
76 }
77}
78impl From<zerodds_durability_store::StoreError> for ServiceError {
79 fn from(e: zerodds_durability_store::StoreError) -> Self {
80 Self::Store(e)
81 }
82}
83
84/// Result alias.
85pub type Result<T> = core::result::Result<T, ServiceError>;
86
87/// Reader QoS for ingest: receive every sample reliably, **including a matched
88/// writer's TransientLocal history** (O2 P5). A service that starts after a
89/// writer published now back-fetches that writer's history. The history+live
90/// overlap — and a service restart that re-receives the same history — are
91/// deduped by the pump on `(writer_guid, source_sequence_number)`, which
92/// `UserSample::Alive` now carries and the store persists; without that dedup
93/// this had to be `VOLATILE` to avoid double-storing.
94fn ingest_reader_qos() -> DataReaderQos {
95 let mut q = DataReaderQos::default();
96 q.reliability.kind = ReliabilityKind::Reliable;
97 q.durability.kind = DurabilityKind::TransientLocal;
98 q.history.kind = HistoryKind::KeepAll;
99 q.resource_limits.max_samples = LENGTH_UNLIMITED;
100 q
101}
102
103/// Writer QoS for replay: hold the full history and deliver it to late-joiners.
104fn replay_writer_qos() -> DataWriterQos {
105 let mut q = DataWriterQos::default();
106 q.reliability.kind = ReliabilityKind::Reliable;
107 q.durability.kind = DurabilityKind::TransientLocal;
108 q.history.kind = HistoryKind::KeepAll;
109 q.resource_limits.max_samples = LENGTH_UNLIMITED;
110 q
111}
112
113struct Served {
114 name: String,
115 stop: Arc<AtomicBool>,
116 handle: Option<JoinHandle<()>>,
117}
118
119/// The durability daemon. Uses TWO participants per domain — one for ingest
120/// (reader) and one for replay (writer) — that mutually **ignore each other**.
121/// This is set up before any endpoint exists, so the ingest reader can never
122/// match the replay writer (race-free echo-loop prevention). Both serve any
123/// number of TRANSIENT/PERSISTENT topics over a single [`DurabilityStore`].
124pub struct DurabilityService {
125 ingest: DomainParticipant,
126 replay: DomainParticipant,
127 publisher: Publisher,
128 subscriber: Subscriber,
129 store: Arc<dyn DurabilityStore>,
130 served: Mutex<Vec<Served>>,
131}
132
133impl DurabilityService {
134 /// Starts the daemon on `domain`, backed by `store`.
135 ///
136 /// # Errors
137 /// Participant creation failed.
138 pub fn start(domain: i32, store: Arc<dyn DurabilityStore>) -> Result<Self> {
139 let factory = DomainParticipantFactory::instance();
140 let ingest = factory.create_participant(domain, DomainParticipantQos::default())?;
141 let replay = factory.create_participant(domain, DomainParticipantQos::default())?;
142 // Race-free: mutual participant ignore BEFORE any reader/writer exists,
143 // so the ingest reader and replay writer never match each other. Use the
144 // GUID-derived `participant_handle()` (the discovery/ignore key space),
145 // NOT `instance_handle()` (a local allocator counter that the ignore
146 // filter never sees).
147 let _ = ingest.ignore_participant(replay.participant_handle());
148 let _ = replay.ignore_participant(ingest.participant_handle());
149 let publisher = replay.create_publisher(PublisherQos::default());
150 let subscriber = ingest.create_subscriber(SubscriberQos::default());
151 Ok(Self {
152 ingest,
153 replay,
154 publisher,
155 subscriber,
156 store,
157 served: Mutex::new(Vec::new()),
158 })
159 }
160
161 fn served(&self) -> Result<MutexGuard<'_, Vec<Served>>> {
162 self.served
163 .lock()
164 .map_err(|_| ServiceError::Poisoned("served topics"))
165 }
166
167 /// Begins serving `topic_name` with the given retention `contract`. Creates
168 /// the ingest reader + replay writer, primes the writer from the store
169 /// (startup-sync), and spawns the ingest pump.
170 ///
171 /// # Errors
172 /// Topic/reader/writer creation or the initial store/replay failed.
173 pub fn serve(&self, topic_name: &str, contract: Contract) -> Result<()> {
174 self.store.set_contract(topic_name, contract)?;
175
176 // Topics are per-participant: the reader's on the ingest participant,
177 // the writer's on the replay participant.
178 let rtopic: Topic<RawBytes> = self
179 .ingest
180 .create_topic::<RawBytes>(topic_name, TopicQos::default())?;
181 let wtopic: Topic<RawBytes> = self
182 .replay
183 .create_topic::<RawBytes>(topic_name, TopicQos::default())?;
184 let writer = self
185 .publisher
186 .create_datawriter::<RawBytes>(&wtopic, replay_writer_qos())?;
187 let reader = self
188 .subscriber
189 .create_datareader::<RawBytes>(&rtopic, ingest_reader_qos())?;
190
191 // Startup-sync: prime the replay writer's history from durable storage.
192 for sample in self.store.replay_for_topic(topic_name)? {
193 writer.write(&RawBytes::new(sample.payload))?;
194 }
195
196 let stop = Arc::new(AtomicBool::new(false));
197 let pump_stop = Arc::clone(&stop);
198 let store = Arc::clone(&self.store);
199 let topic_owned = topic_name.to_string();
200 let own_pub = writer.instance_handle();
201 let seq = AtomicU64::new(self.store.stats(topic_name)?.samples as u64);
202
203 let handle = std::thread::Builder::new()
204 .name(format!("durability-pump-{topic_name}"))
205 .spawn(move || {
206 pump(
207 &pump_stop,
208 &reader,
209 &writer,
210 store.as_ref(),
211 &topic_owned,
212 own_pub,
213 &seq,
214 );
215 })
216 .map_err(|_| ServiceError::Poisoned("spawn pump"))?;
217
218 self.served()?.push(Served {
219 name: topic_name.to_string(),
220 stop,
221 handle: Some(handle),
222 });
223 Ok(())
224 }
225
226 /// Like [`serve`](Self::serve) but for a topic whose application type-name
227 /// is `type_name` rather than the native `zerodds::RawBytes` — e.g. a
228 /// foreign-vendor `TRANSIENT` writer (Cyclone/FastDDS/OpenDDS) discovered on
229 /// the wire. The typed `RawBytes` topic is locked to `zerodds::RawBytes`, so
230 /// it would never match a foreign writer under `use_xtypes=no`
231 /// (match keyed on `topic_name` + `type_name`). This path uses the
232 /// **runtime-level** user-entity API so the ingest reader + replay writer
233 /// register under the *discovered* `type_name` (and the matching
234 /// keyed/no-key kind) — exactly how the byte-oriented C-FFI achieves
235 /// cross-vendor matching.
236 ///
237 /// Echo is avoided structurally: the ingest reader lives on the `ingest`
238 /// participant and the replay writer on the `replay` participant, which
239 /// mutually `ignore_participant` each other (set in [`start`](Self::start)).
240 ///
241 /// `keyed` must match the foreign writer's key kind (RTPS entityKind, Spec
242 /// §9.3.1.2). Increment-1 scope is unkeyed; `false` is the safe default.
243 ///
244 /// # Errors
245 /// Participant runtime unavailable, reader/writer registration failed, or
246 /// the initial store read / replay-prime failed.
247 pub fn serve_typed(
248 &self,
249 topic_name: &str,
250 type_name: &str,
251 keyed: bool,
252 contract: Contract,
253 ) -> Result<()> {
254 self.store.set_contract(topic_name, contract)?;
255
256 let irt = self
257 .ingest
258 .runtime()
259 .ok_or(ServiceError::Poisoned("ingest runtime"))?
260 .clone();
261 let rrt = self
262 .replay
263 .runtime()
264 .ok_or(ServiceError::Poisoned("replay runtime"))?
265 .clone();
266
267 let weid = rrt
268 .register_user_writer_kind(user_writer_cfg(topic_name, type_name), keyed)
269 .map_err(|_| ServiceError::Poisoned("register replay writer"))?;
270 let (_reid, rx) = irt
271 .register_user_reader_kind(user_reader_cfg(topic_name, type_name), keyed)
272 .map_err(|_| ServiceError::Poisoned("register ingest reader"))?;
273
274 // Startup-sync: prime the replay writer's history from durable storage.
275 // Each stored sample carries its own representation + byte order; set
276 // the writer overrides so the primed replay re-declares the original
277 // wire (a big-endian peer's persisted sample replays with a BE encap).
278 for sample in self.store.replay_for_topic(topic_name)? {
279 let off: i16 = if sample.representation == 1 { 2 } else { 0 };
280 let _ = rrt.set_user_writer_data_rep_override(weid, Some(vec![off]));
281 let _ = rrt.set_user_writer_byte_order_override(weid, sample.big_endian);
282 let _ = rrt.write_user_sample_borrowed(weid, &sample.payload);
283 }
284
285 let stop = Arc::new(AtomicBool::new(false));
286 let pump_stop = Arc::clone(&stop);
287 let store = Arc::clone(&self.store);
288 let topic_owned = topic_name.to_string();
289 let pump_rrt = Arc::clone(&rrt);
290 let seq = AtomicU64::new(self.store.stats(topic_name)?.samples as u64);
291
292 let handle = std::thread::Builder::new()
293 .name(format!("durability-pump-{topic_name}"))
294 .spawn(move || {
295 // Representation-faithful replay: the ingested `payload` is the
296 // CDR body in the SOURCE writer's representation (a foreign
297 // FINAL-type writer emits XCDR1). The replay writer must declare
298 // an encap that matches that body, else a strict foreign reader
299 // misparses an alignment-sensitive type. We track the source
300 // representation and override the replay writer's encap when it
301 // changes (a topic is representation-consistent, so this is
302 // effectively a one-time set). 255 = "not yet observed".
303 let mut last_rep: u8 = 255;
304 // Tracks the last byte order pushed to the replay writer's encap
305 // override, so a BE peer's samples replay with a BE encap header.
306 let mut last_be = false;
307 // Seed the source-identity dedup set from what is already
308 // durable, so a restart's re-received TransientLocal history is
309 // recognised and not re-stored.
310 let mut seen: std::collections::HashSet<([u8; 16], i64)> = store
311 .replay_for_topic(&topic_owned)
312 .map(|v| {
313 v.into_iter()
314 .filter(|s| s.source_sequence >= 0)
315 .map(|s| (s.source_guid, s.source_sequence))
316 .collect()
317 })
318 .unwrap_or_default();
319 // Event-driven: block on the reader's channel (no busy-poll),
320 // waking on each sample or the 200ms stop-flag re-check.
321 while !pump_stop.load(Ordering::Relaxed) {
322 let sample = match rx.recv_timeout(Duration::from_millis(200)) {
323 Ok(s) => s,
324 Err(mpsc::RecvTimeoutError::Timeout) => continue,
325 Err(mpsc::RecvTimeoutError::Disconnected) => break,
326 };
327 let (payload, representation, big_endian, writer_guid, source_seq) =
328 match sample {
329 UserSample::Alive {
330 payload,
331 representation,
332 big_endian,
333 writer_guid,
334 source_sequence_number,
335 ..
336 } => (
337 payload.to_vec(),
338 representation,
339 big_endian,
340 writer_guid,
341 source_sequence_number,
342 ),
343 UserSample::Lifecycle { .. } => continue,
344 };
345 // O2 P5 dedup: a wire-delivered sample carries its source
346 // identity (writer_guid, source_seq). Skip one already
347 // stored — this makes a TransientLocal ingest reader safe
348 // (history+live overlap, and a service restart that
349 // re-receives a writer's history) without duplicating.
350 if source_seq >= 0 && !seen.insert((writer_guid, source_seq)) {
351 continue;
352 }
353 if representation != last_rep {
354 // UserSample.representation: 0 = XCDR1, 1 = XCDR2 →
355 // data_representation offer id 0 (XCDR) / 2 (XCDR2).
356 let off: i16 = if representation == 1 { 2 } else { 0 };
357 let _ = pump_rrt.set_user_writer_data_rep_override(weid, Some(vec![off]));
358 last_rep = representation;
359 }
360 if big_endian != last_be {
361 // The stored body bytes are big-endian for a BE peer, so
362 // the replay writer must emit a matching `_BE` encap
363 // header — otherwise the late-joiner mis-decodes.
364 let _ = pump_rrt.set_user_writer_byte_order_override(weid, big_endian);
365 last_be = big_endian;
366 }
367 let ds = DurabilitySample {
368 topic: topic_owned.clone(),
369 instance_key: [0u8; 16], // unkeyed (increment 1)
370 sequence: seq.fetch_add(1, Ordering::Relaxed),
371 payload: payload.clone(),
372 representation,
373 big_endian,
374 created_at: SystemTime::now(),
375 source_guid: writer_guid,
376 source_sequence: source_seq,
377 };
378 if store.store(ds).is_ok() {
379 // Grow the replay writer's history for future late-joiners.
380 let _ = pump_rrt.write_user_sample_borrowed(weid, &payload);
381 }
382 }
383 })
384 .map_err(|_| ServiceError::Poisoned("spawn pump"))?;
385
386 self.served()?.push(Served {
387 name: topic_name.to_string(),
388 stop,
389 handle: Some(handle),
390 });
391 Ok(())
392 }
393
394 /// Topics currently served.
395 ///
396 /// # Errors
397 /// Lock poisoned.
398 pub fn served_topics(&self) -> Result<Vec<String>> {
399 Ok(self.served()?.iter().map(|s| s.name.clone()).collect())
400 }
401
402 /// Auto-serve any topic whose discovered writers declare a durability of
403 /// `TRANSIENT` or `PERSISTENT` — observed via the standard `DCPSPublication`
404 /// builtin reader. This needs no application-side code: apps simply set
405 /// their `DurabilityQosPolicy`, and the service picks the topic up. Topics
406 /// use `default_contract` (the `DurabilityServiceQosPolicy` is not carried
407 /// in discovery; a per-topic override would come from service config).
408 ///
409 /// # Errors
410 /// Spawning the discovery thread failed.
411 pub fn enable_auto_discovery(self: &Arc<Self>, default_contract: Contract) -> Result<()> {
412 let reader = self.ingest.get_builtin_subscriber().publication_reader();
413 let me = Arc::clone(self);
414 let stop = Arc::new(AtomicBool::new(false));
415 let pstop = Arc::clone(&stop);
416 let handle = std::thread::Builder::new()
417 .name("durability-autodiscover".to_string())
418 .spawn(move || {
419 while !pstop.load(Ordering::Relaxed) {
420 let _ = reader.wait_for_data(Duration::from_millis(300));
421 let Ok(pubs) = reader.take_with_info() else {
422 continue;
423 };
424 for s in pubs {
425 if !s.info.valid_data || s.data.durability < DurabilityKind::Transient {
426 continue;
427 }
428 let topic = s.data.topic_name;
429 let type_name = s.data.type_name;
430 let already = me
431 .served_topics()
432 .map(|v| v.iter().any(|t| t == &topic))
433 .unwrap_or(true);
434 if !already {
435 // Runtime-level path keyed on the *discovered* type
436 // name → matches foreign-vendor writers too (the
437 // typed RawBytes topic is locked to
438 // `zerodds::RawBytes`). Increment-1 scope: unkeyed.
439 let _ = me.serve_typed(&topic, &type_name, false, default_contract);
440 }
441 }
442 }
443 })
444 .map_err(|_| ServiceError::Poisoned("spawn auto-discovery"))?;
445 self.served()?.push(Served {
446 name: "<auto-discovery>".to_string(),
447 stop,
448 handle: Some(handle),
449 });
450 Ok(())
451 }
452
453 /// Stops all pumps + the auto-discovery thread and tears the daemon down.
454 /// Takes `&self` (not `self`) so it is callable on an `Arc<DurabilityService>`
455 /// even while the auto-discovery thread still holds a clone. Drains the join
456 /// handles under the lock, then joins WITHOUT holding it (the auto-discovery
457 /// thread may itself be inside `serve` taking the lock).
458 pub fn shutdown(&self) {
459 let handles: Vec<JoinHandle<()>> = {
460 let Ok(mut served) = self.served.lock() else {
461 return;
462 };
463 for s in served.iter() {
464 s.stop.store(true, Ordering::Relaxed);
465 }
466 served.iter_mut().filter_map(|s| s.handle.take()).collect()
467 };
468 for h in handles {
469 let _ = h.join();
470 }
471 }
472}
473
474/// Ingest pump: receive → store → replay-write, until `stop`.
475fn pump(
476 stop: &AtomicBool,
477 reader: &DataReader<RawBytes>,
478 writer: &DataWriter<RawBytes>,
479 store: &dyn DurabilityStore,
480 topic: &str,
481 own_pub: zerodds_dcps::InstanceHandle,
482 seq: &AtomicU64,
483) {
484 while !stop.load(Ordering::Relaxed) {
485 // Block until data or a short timeout (re-checks the stop flag).
486 let _ = reader.wait_for_data(Duration::from_millis(200));
487 let samples = match reader.take_with_info() {
488 Ok(s) => s,
489 Err(_) => continue,
490 };
491 for sample in samples {
492 if !sample.info.valid_data {
493 continue; // lifecycle marker, no payload
494 }
495 // Skip our own replay echo (defence in depth beyond ignore_publication).
496 if sample.info.publication_handle == own_pub {
497 continue;
498 }
499 let payload = sample.data.data;
500 let ds = DurabilitySample {
501 topic: topic.to_string(),
502 instance_key: [0u8; 16], // unkeyed (increment 1)
503 sequence: seq.fetch_add(1, Ordering::Relaxed),
504 payload: payload.clone(),
505 // The high-level `DataReader<RawBytes>` SampleInfo does not
506 // surface the wire representation / byte order, so this DDS-API
507 // ingest path assumes the canonical XCDR2 little-endian wire.
508 // For a big-endian peer use `serve_typed` (the runtime-level
509 // UserSample path), which captures both and replays them.
510 representation: 1,
511 big_endian: false,
512 created_at: SystemTime::now(),
513 // The high-level DataReader<RawBytes> path surfaces no source
514 // identity, so this path does not participate in P5 dedup.
515 source_guid: [0u8; 16],
516 source_sequence: -1,
517 };
518 if store.store(ds).is_ok() {
519 // Grow the replay writer's history so future late-joiners get it.
520 let _ = writer.write(&RawBytes::new(payload));
521 }
522 }
523 }
524}
525
526/// Runtime-level ingest-reader config mirroring [`ingest_reader_qos`]
527/// (RELIABLE + VOLATILE) but with an explicit `type_name` for cross-vendor
528/// matching. KEEP_ALL history is the reader default at the runtime level.
529fn user_reader_cfg(topic_name: &str, type_name: &str) -> UserReaderConfig {
530 UserReaderConfig {
531 topic_name: topic_name.to_string(),
532 type_name: type_name.to_string(),
533 reliable: true,
534 durability: DurabilityKind::Volatile,
535 deadline: DeadlineQosPolicy::default(),
536 liveliness: LivelinessQosPolicy {
537 kind: LivelinessKind::Automatic,
538 ..Default::default()
539 },
540 ownership: OwnershipKind::Shared,
541 partition: Vec::new(),
542 user_data: Vec::new(),
543 topic_data: Vec::new(),
544 group_data: Vec::new(),
545 // Cross-vendor byte-path: no local TypeObject (matched by topic+name).
546 type_identifier: zerodds_types::TypeIdentifier::None,
547 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
548 // Accept BOTH XCDR1 (0) and XCDR2 (2): a foreign-vendor writer of a
549 // FINAL-extensibility type offers only XCDR1 (verified live against
550 // CycloneDDS — a final `dur::Sample` writer advertises
551 // data_representation=0). An XCDR2-only reader is RxO-incompatible and
552 // never matches it. A durability service must ingest whatever
553 // representation the source emits, so it offers both.
554 data_representation_offer: Some(vec![0, 2]),
555 }
556}
557
558/// Runtime-level replay-writer config mirroring [`replay_writer_qos`]
559/// (RELIABLE + TRANSIENT_LOCAL/KEEP_ALL) with an explicit `type_name`.
560fn user_writer_cfg(topic_name: &str, type_name: &str) -> UserWriterConfig {
561 UserWriterConfig {
562 topic_name: topic_name.to_string(),
563 type_name: type_name.to_string(),
564 reliable: true,
565 durability: DurabilityKind::TransientLocal,
566 deadline: DeadlineQosPolicy::default(),
567 lifespan: LifespanQosPolicy::default(),
568 liveliness: LivelinessQosPolicy {
569 kind: LivelinessKind::Automatic,
570 ..Default::default()
571 },
572 ownership: OwnershipKind::Shared,
573 ownership_strength: 0,
574 partition: Vec::new(),
575 user_data: Vec::new(),
576 topic_data: Vec::new(),
577 group_data: Vec::new(),
578 type_identifier: zerodds_types::TypeIdentifier::None,
579 data_representation_offer: None,
580 }
581}