Skip to main content

zerodds_durability_store_file/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! File-per-sample cold adapter for the Durability-Service (ADR 0009).
5//!
6//! Crate `zerodds-durability-store-file`. Safety classification: **STANDARD**.
7//!
8//! Dependency-free `PERSISTENT` backend on a directory hierarchy:
9//!
10//! ```text
11//! <root>/<hex(topic)>/<hex(instance_key)>/<sequence>.bin   # [8B created_at_nanos LE][payload]
12//! <root>/<hex(topic)>/<hex(instance_key)>/.unregistered    # unregister wall-clock (unix nanos, ASCII)
13//! ```
14//!
15//! The topic and instance components are hex-encoded so the mapping is
16//! lossless and filesystem-safe (unlike a lossy sanitizer). The per-sample
17//! header persists `created_at`, so the store survives a process restart with
18//! full fidelity — that is exactly the `PERSISTENT` guarantee.
19//!
20//! Retention follows the topic [`Contract`] (set via
21//! [`DurabilityStore::set_contract`], or persisted along with the topic's
22//! type name/keyedness via [`DurabilityStore::set_topic_meta`] — a `.meta`
23//! sidecar file per topic directory, so the daemon does not need to
24//! re-register it on every restart, only on a topic it serves for the very
25//! first time).
26
27#![forbid(unsafe_code)]
28
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31use std::sync::Mutex;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34use zerodds_durability_store::{
35    Contract, Cursor, DurabilitySample, DurabilityStore, Page, Result, Selector, StoreError,
36    StoreStats, TopicMeta,
37};
38use zerodds_qos::policies::history::HistoryKind;
39
40// On-disk per-sample header: [8B created_at_nanos LE][1B representation]
41// [1B big_endian] then the payload body.
42// [8B created_at_nanos LE][1B representation][1B big_endian][16B source_guid]
43// [8B source_sequence LE]. The source-identity tail (O2 P5) extended the header
44// from 10 to 34 bytes; pre-P5 files are not read back (pre-1.0 format break).
45const HEADER_LEN: usize = 34;
46const DEFAULT_PAGE: usize = 1024;
47
48/// The name of the per-topic metadata sidecar file (module docs,
49/// "restart-survival of served topics"). Additive: an existing topic
50/// directory from before this file existed has none, and
51/// [`FileStore::topic_meta`] returns `Ok(None)` for it — the old on-disk
52/// format still opens.
53const META_FILE: &str = ".meta";
54
55/// File-per-sample durability store rooted at a directory.
56pub struct FileStore {
57    root: PathBuf,
58    contracts: Mutex<BTreeMap<String, Contract>>,
59    default_contract: Contract,
60}
61
62fn backend<E: core::fmt::Display>(ctx: &str, e: E) -> StoreError {
63    StoreError::Backend(format!("file store: {ctx}: {e}"))
64}
65
66fn hex(bytes: &[u8]) -> String {
67    let mut s = String::with_capacity(bytes.len() * 2);
68    for b in bytes {
69        s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
70        s.push(char::from_digit(u32::from(b & 0xF), 16).unwrap_or('0'));
71    }
72    s
73}
74
75fn unhex(s: &str) -> Option<Vec<u8>> {
76    if s.len() % 2 != 0 {
77        return None;
78    }
79    let b = s.as_bytes();
80    let mut out = Vec::with_capacity(s.len() / 2);
81    let mut i = 0;
82    while i < b.len() {
83        let hi = (b[i] as char).to_digit(16)?;
84        let lo = (b[i + 1] as char).to_digit(16)?;
85        out.push(((hi << 4) | lo) as u8);
86        i += 2;
87    }
88    Some(out)
89}
90
91fn nanos_of(t: SystemTime) -> u64 {
92    t.duration_since(UNIX_EPOCH)
93        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
94        .unwrap_or(0)
95}
96
97fn time_of(nanos: u64) -> SystemTime {
98    UNIX_EPOCH + Duration::from_nanos(nanos)
99}
100
101/// `.meta` sidecar wire format: `[4B type_name_len LE][type_name bytes]
102/// [1B keyed][4B history_kind LE][4B history_depth LE][4B max_samples LE]
103/// [4B max_instances LE][4B max_samples_per_instance LE]
104/// [8B cleanup_delay_secs LE][4B cleanup_delay_subsec_nanos LE]`.
105fn encode_topic_meta(meta: &TopicMeta) -> Vec<u8> {
106    let (
107        history_kind,
108        history_depth,
109        max_samples,
110        max_instances,
111        max_samples_per_instance,
112        secs,
113        nanos,
114    ) = meta.contract.to_row();
115    let name = meta.type_name.as_bytes();
116    let mut buf = Vec::with_capacity(4 + name.len() + 34);
117    buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
118    buf.extend_from_slice(name);
119    buf.push(u8::from(meta.keyed));
120    buf.extend_from_slice(&history_kind.to_le_bytes());
121    buf.extend_from_slice(&history_depth.to_le_bytes());
122    buf.extend_from_slice(&max_samples.to_le_bytes());
123    buf.extend_from_slice(&max_instances.to_le_bytes());
124    buf.extend_from_slice(&max_samples_per_instance.to_le_bytes());
125    buf.extend_from_slice(&secs.to_le_bytes());
126    buf.extend_from_slice(&nanos.to_le_bytes());
127    buf
128}
129
130/// Inverse of [`encode_topic_meta`]. `None` on a short buffer, invalid UTF-8
131/// type name, or an unknown `history_kind` discriminator — the caller (
132/// [`FileStore::read_meta`]) turns that into a loud `Err`, never a silent
133/// `None`.
134fn decode_topic_meta(raw: &[u8]) -> Option<TopicMeta> {
135    let name_len = u32::from_le_bytes(raw.get(0..4)?.try_into().ok()?) as usize;
136    let mut pos = 4usize.checked_add(name_len)?;
137    let type_name = String::from_utf8(raw.get(4..pos)?.to_vec()).ok()?;
138    let keyed = *raw.get(pos)? != 0;
139    pos += 1;
140    let history_kind = u32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
141    pos += 4;
142    let history_depth = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
143    pos += 4;
144    let max_samples = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
145    pos += 4;
146    let max_instances = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
147    pos += 4;
148    let max_samples_per_instance = i32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
149    pos += 4;
150    let secs = u64::from_le_bytes(raw.get(pos..pos + 8)?.try_into().ok()?);
151    pos += 8;
152    let nanos = u32::from_le_bytes(raw.get(pos..pos + 4)?.try_into().ok()?);
153    let contract = Contract::from_row(
154        history_kind,
155        history_depth,
156        max_samples,
157        max_instances,
158        max_samples_per_instance,
159        secs,
160        nanos,
161    )?;
162    Some(TopicMeta {
163        type_name,
164        keyed,
165        contract,
166    })
167}
168
169impl FileStore {
170    /// Opens (creating if absent) a file store at `root`, with `default_contract`
171    /// for topics that have no explicit contract.
172    ///
173    /// # Errors
174    /// Filesystem error creating the root directory.
175    pub fn open<P: Into<PathBuf>>(root: P, default_contract: Contract) -> Result<Self> {
176        let root = root.into();
177        std::fs::create_dir_all(&root).map_err(|e| backend("create root", e))?;
178        Ok(Self {
179            root,
180            contracts: Mutex::new(BTreeMap::new()),
181            default_contract,
182        })
183    }
184
185    /// The persisted [`TopicMeta`]'s contract wins over a plain
186    /// `set_contract` for the same topic (it is the more recent, more
187    /// complete registration — see [`DurabilityStore::set_contract`]'s doc).
188    fn contract_for(&self, topic: &str) -> Result<Contract> {
189        if let Some(meta) = self.read_meta(topic)? {
190            return Ok(meta.contract);
191        }
192        Ok(self
193            .contracts
194            .lock()
195            .map_err(|_| StoreError::Poisoned("file store contracts"))?
196            .get(topic)
197            .copied()
198            .unwrap_or(self.default_contract))
199    }
200
201    fn topic_dir(&self, topic: &str) -> PathBuf {
202        self.root.join(hex(topic.as_bytes()))
203    }
204
205    fn meta_path(&self, topic: &str) -> PathBuf {
206        self.topic_dir(topic).join(META_FILE)
207    }
208
209    /// Reads `topic`'s persisted [`TopicMeta`] sidecar, if any. `Ok(None)`
210    /// when the file is absent (a topic that never called
211    /// [`DurabilityStore::set_topic_meta`], or an on-disk store from before
212    /// this file existed — the old format still opens). A PRESENT but
213    /// unparseable file is a loud `Err`, never a silent `None` (a corrupt
214    /// sidecar must not be indistinguishable from "never served").
215    fn read_meta(&self, topic: &str) -> Result<Option<TopicMeta>> {
216        let path = self.meta_path(topic);
217        let raw = match std::fs::read(&path) {
218            Ok(b) => b,
219            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
220            Err(e) => return Err(backend("read topic meta", e)),
221        };
222        decode_topic_meta(&raw).map(Some).ok_or_else(|| {
223            StoreError::Backend(format!(
224                "file store: corrupt topic meta sidecar: {}",
225                path.display()
226            ))
227        })
228    }
229
230    /// Writes `topic`'s [`TopicMeta`] sidecar atomically (write to a
231    /// process/thread-unique temp file, then `rename` — POSIX rename is
232    /// atomic within one filesystem), so a `kill -9` mid-write leaves either
233    /// the old content or the new one, never a half-written file a later
234    /// [`Self::read_meta`] would have to reject.
235    fn write_meta(&self, topic: &str, meta: &TopicMeta) -> Result<()> {
236        let dir = self.topic_dir(topic);
237        std::fs::create_dir_all(&dir).map_err(|e| backend("mkdir topic", e))?;
238        let buf = encode_topic_meta(meta);
239        let tmp = dir.join(format!(
240            ".meta.tmp-{}-{:?}",
241            std::process::id(),
242            std::thread::current().id()
243        ));
244        std::fs::write(&tmp, &buf).map_err(|e| backend("write topic meta tmp", e))?;
245        std::fs::rename(&tmp, dir.join(META_FILE)).map_err(|e| backend("rename topic meta", e))?;
246        Ok(())
247    }
248
249    fn instance_dir(&self, topic: &str, key: &[u8; 16]) -> PathBuf {
250        self.topic_dir(topic).join(hex(key))
251    }
252
253    /// Lists `(sequence, path)` of sample files in an instance dir, sorted by
254    /// sequence.
255    fn list_samples(dir: &Path) -> Vec<(u64, PathBuf)> {
256        let mut out = Vec::new();
257        if let Ok(rd) = std::fs::read_dir(dir) {
258            for e in rd.flatten() {
259                let name = e.file_name().to_string_lossy().into_owned();
260                if let Some(stem) = name.strip_suffix(".bin") {
261                    if let Ok(seq) = stem.parse::<u64>() {
262                        out.push((seq, e.path()));
263                    }
264                }
265            }
266        }
267        out.sort_by_key(|(s, _)| *s);
268        out
269    }
270
271    fn list_instances(&self, topic: &str) -> Vec<[u8; 16]> {
272        let mut out = Vec::new();
273        if let Ok(rd) = std::fs::read_dir(self.topic_dir(topic)) {
274            for e in rd.flatten() {
275                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
276                    if let Some(bytes) = unhex(&e.file_name().to_string_lossy()) {
277                        if let Ok(k) = <[u8; 16]>::try_from(bytes.as_slice()) {
278                            out.push(k);
279                        }
280                    }
281                }
282            }
283        }
284        out.sort_unstable();
285        out
286    }
287
288    fn read_sample(topic: &str, key: [u8; 16], seq: u64, path: &Path) -> Option<DurabilitySample> {
289        let raw = std::fs::read(path).ok()?;
290        if raw.len() < HEADER_LEN {
291            return None;
292        }
293        let nanos = u64::from_le_bytes(raw[..8].try_into().ok()?);
294        let mut source_guid = [0u8; 16];
295        source_guid.copy_from_slice(&raw[10..26]);
296        let source_sequence = i64::from_le_bytes(raw[26..34].try_into().ok()?);
297        Some(DurabilitySample {
298            topic: topic.to_string(),
299            instance_key: key,
300            sequence: seq,
301            payload: raw[HEADER_LEN..].to_vec(),
302            representation: raw[8],
303            big_endian: raw[9] != 0,
304            created_at: time_of(nanos),
305            source_guid,
306            source_sequence,
307        })
308    }
309
310    fn topic_sample_count(&self, topic: &str) -> usize {
311        self.list_instances(topic)
312            .iter()
313            .map(|k| Self::list_samples(&self.instance_dir(topic, k)).len())
314            .sum()
315    }
316}
317
318impl DurabilityStore for FileStore {
319    fn set_contract(&self, topic: &str, contract: Contract) -> Result<()> {
320        self.contracts
321            .lock()
322            .map_err(|_| StoreError::Poisoned("file store contracts"))?
323            .insert(topic.to_string(), contract);
324        Ok(())
325    }
326
327    fn set_topic_meta(&self, topic: &str, meta: TopicMeta) -> Result<()> {
328        self.write_meta(topic, &meta)
329    }
330
331    fn topic_meta(&self, topic: &str) -> Result<Option<TopicMeta>> {
332        self.read_meta(topic)
333    }
334
335    fn store(&self, sample: DurabilitySample) -> Result<()> {
336        let contract = self.contract_for(&sample.topic)?;
337        let inst_dir = self.instance_dir(&sample.topic, &sample.instance_key);
338        let new_instance = !inst_dir.exists();
339
340        // A re-send of an existing sequence overwrites its `<seq>.bin` — an
341        // idempotent replace that grows nothing, so no cap may reject it.
342        let is_resend = inst_dir.join(format!("{}.bin", sample.sequence)).exists();
343
344        if !is_resend
345            && contract.samples_bounded()
346            && matches!(contract.history_kind, HistoryKind::KeepAll)
347            && self.topic_sample_count(&sample.topic) >= contract.max_samples as usize
348        {
349            return Err(StoreError::OutOfResources("max_samples"));
350        }
351        if new_instance
352            && contract.instances_bounded()
353            && self.list_instances(&sample.topic).len() >= contract.max_instances as usize
354        {
355            return Err(StoreError::OutOfResources("max_instances"));
356        }
357        std::fs::create_dir_all(&inst_dir).map_err(|e| backend("mkdir instance", e))?;
358
359        match contract.history_kind {
360            HistoryKind::KeepLast => {
361                let depth = contract.effective_depth();
362                let existing = Self::list_samples(&inst_dir);
363                // After adding one, keep only the newest `depth`.
364                let mut to_remove = existing.len() as isize + 1 - depth as isize;
365                for (_, p) in existing {
366                    if to_remove <= 0 {
367                        break;
368                    }
369                    let _ = std::fs::remove_file(&p);
370                    to_remove -= 1;
371                }
372            }
373            HistoryKind::KeepAll => {
374                if !is_resend
375                    && contract.per_instance_bounded()
376                    && Self::list_samples(&inst_dir).len()
377                        >= contract.max_samples_per_instance as usize
378                {
379                    return Err(StoreError::OutOfResources("max_samples_per_instance"));
380                }
381            }
382        }
383
384        let mut buf = Vec::with_capacity(HEADER_LEN + sample.payload.len());
385        buf.extend_from_slice(&nanos_of(sample.created_at).to_le_bytes());
386        buf.push(sample.representation);
387        buf.push(u8::from(sample.big_endian));
388        buf.extend_from_slice(&sample.source_guid);
389        buf.extend_from_slice(&sample.source_sequence.to_le_bytes());
390        buf.extend_from_slice(&sample.payload);
391        let path = inst_dir.join(format!("{}.bin", sample.sequence));
392        std::fs::write(&path, &buf).map_err(|e| backend("write sample", e))?;
393        Ok(())
394    }
395
396    fn query(&self, topic: &str, selector: &Selector) -> Result<Page> {
397        let mut matched: Vec<DurabilitySample> = Vec::new();
398        let instances = match selector.instance_key {
399            Some(k) => vec![k],
400            None => self.list_instances(topic),
401        };
402        for key in instances {
403            let dir = self.instance_dir(topic, &key);
404            for (seq, path) in Self::list_samples(&dir) {
405                if let Some(s) = Self::read_sample(topic, key, seq, &path) {
406                    if selector.matches(&s) {
407                        matched.push(s);
408                    }
409                }
410            }
411        }
412        matched.sort_by_key(|s| (s.instance_key, s.sequence));
413        let limit = selector.limit.unwrap_or(DEFAULT_PAGE);
414        let exhausted = matched.len() <= limit;
415        matched.truncate(limit);
416        let next: Option<Cursor> = if exhausted {
417            None
418        } else {
419            matched.last().map(|s| (s.instance_key, s.sequence))
420        };
421        Ok(Page {
422            samples: matched,
423            next,
424        })
425    }
426
427    fn unregister(&self, topic: &str, instance_key: &[u8; 16], now: SystemTime) -> Result<()> {
428        let dir = self.instance_dir(topic, instance_key);
429        if dir.exists() {
430            let marker = dir.join(".unregistered");
431            std::fs::write(&marker, nanos_of(now).to_string())
432                .map_err(|e| backend("write unregister marker", e))?;
433        }
434        Ok(())
435    }
436
437    fn cleanup(&self, now: SystemTime) -> Result<usize> {
438        let mut removed = 0usize;
439        let topic_dirs = match std::fs::read_dir(&self.root) {
440            Ok(d) => d,
441            Err(_) => return Ok(0),
442        };
443        for topic_entry in topic_dirs.flatten() {
444            let Some(topic_bytes) = unhex(&topic_entry.file_name().to_string_lossy()) else {
445                continue;
446            };
447            let topic = String::from_utf8_lossy(&topic_bytes).into_owned();
448            let delay = self.contract_for(&topic)?.cleanup_delay;
449            let Ok(inst_dirs) = std::fs::read_dir(topic_entry.path()) else {
450                continue;
451            };
452            for inst_entry in inst_dirs.flatten() {
453                let marker = inst_entry.path().join(".unregistered");
454                let Ok(content) = std::fs::read_to_string(&marker) else {
455                    continue;
456                };
457                let Ok(nanos) = content.trim().parse::<u64>() else {
458                    continue;
459                };
460                if let Some(deadline) = time_of(nanos).checked_add(delay) {
461                    if now >= deadline {
462                        let _ = std::fs::remove_dir_all(inst_entry.path());
463                        removed += 1;
464                    }
465                }
466            }
467        }
468        Ok(removed)
469    }
470
471    fn stats(&self, topic: &str) -> Result<StoreStats> {
472        let mut stats = StoreStats::default();
473        for key in self.list_instances(topic) {
474            stats.instances += 1;
475            for (seq, path) in Self::list_samples(&self.instance_dir(topic, &key)) {
476                if let Some(s) = Self::read_sample(topic, key, seq, &path) {
477                    stats.samples += 1;
478                    stats.bytes += s.payload.len() as u64;
479                }
480            }
481        }
482        Ok(stats)
483    }
484}