Skip to main content

uni_sidecar/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Atomic JSON sidecar for `_system/` state files.
5//!
6//! Several uni-db subsystems persist a small document under
7//! `<data_path>/_system/<name>.json` — the CDC checkpoint table, the deferred-
8//! trigger queue, the background-job scheduler (in `uni-plugin-host`), and the
9//! declared-plugins registry (in `uni-plugin-custom`). They all want the same
10//! thing: load the whole document at startup (treating a missing or empty file
11//! as "nothing yet"), and replace it atomically on every write. This crate is
12//! the single, correct implementation of that pattern, shared so no subsystem
13//! re-rolls it.
14//!
15//! "Atomic" here means write-to-temp, `fsync` the temp file, then `rename` over
16//! the target. Crucially it also `fsync`s the *parent directory* after the
17//! rename: on POSIX a rename is not crash-durable until the directory entry is
18//! flushed, so without it a power loss can leave the file reverted to its
19//! pre-rename contents even though the data was synced.
20//!
21//! [`SystemSidecar`] handles only the IO. Higher-level concerns — write
22//! serialization (a mutex spanning read-modify-write), a best-effort Cypher
23//! mirror, per-row re-binding — stay with the callers, which compose them
24//! around [`SystemSidecar::load`] / [`SystemSidecar::store`].
25
26// Rust guideline compliant
27
28use std::fs::File;
29use std::io::Write;
30use std::marker::PhantomData;
31use std::path::{Path, PathBuf};
32
33use serde::Serialize;
34use serde::de::DeserializeOwned;
35
36/// Error raised by [`SystemSidecar`] load/store operations.
37///
38/// Each variant carries the path it was operating on so callers can surface a
39/// useful diagnostic when they convert into their own error type.
40#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum SidecarIoError {
43    /// Creating the `_system/` parent directory failed.
44    #[error("system sidecar create dir {path:?}: {source}")]
45    CreateDir {
46        /// Directory whose creation failed.
47        path: PathBuf,
48        /// Underlying IO error.
49        source: std::io::Error,
50    },
51    /// Reading the sidecar file failed.
52    #[error("system sidecar read {path:?}: {source}")]
53    Read {
54        /// File being read.
55        path: PathBuf,
56        /// Underlying IO error.
57        source: std::io::Error,
58    },
59    /// Writing the temp file, renaming, or fsyncing failed.
60    #[error("system sidecar write {path:?}: {source}")]
61    Write {
62        /// File or directory being written / synced.
63        path: PathBuf,
64        /// Underlying IO error.
65        source: std::io::Error,
66    },
67    /// Serializing the document to JSON failed.
68    #[error("system sidecar encode {path:?}: {source}")]
69    Encode {
70        /// Target file the document was destined for.
71        path: PathBuf,
72        /// Underlying serialization error.
73        source: serde_json::Error,
74    },
75    /// Parsing the sidecar file as JSON failed.
76    #[error("system sidecar decode {path:?}: {source}")]
77    Decode {
78        /// File being parsed.
79        path: PathBuf,
80        /// Underlying deserialization error.
81        source: serde_json::Error,
82    },
83}
84
85/// Build a [`SidecarIoError::Write`] closure for `path`.
86///
87/// The temp-file write / fsync / rename / dir-fsync steps all map their IO error
88/// to `Write { path, source }`; this returns the `map_err` closure so each call
89/// site is `.map_err(write_err(&p))?` instead of a repeated 4-line literal.
90fn write_err(path: &Path) -> impl FnOnce(std::io::Error) -> SidecarIoError + '_ {
91    move |source| SidecarIoError::Write {
92        path: path.to_path_buf(),
93        source,
94    }
95}
96
97/// Atomic JSON persistence for one `_system/` document of type `T`.
98///
99/// `T` is the *whole* document — callers parameterize with the collection they
100/// store (`SystemSidecar<Vec<Row>>`). Persisting `T` rather than a hardwired
101/// `Vec` leaves room for a future versioned envelope without changing this IO
102/// layer.
103pub struct SystemSidecar<T> {
104    path: PathBuf,
105    // `fn() -> T` so the sidecar is unconditionally `Send`/`Sync`/`Clone`:
106    // it produces and consumes `T` but never owns one.
107    _marker: PhantomData<fn() -> T>,
108}
109
110impl<T> Clone for SystemSidecar<T> {
111    fn clone(&self) -> Self {
112        Self {
113            path: self.path.clone(),
114            _marker: PhantomData,
115        }
116    }
117}
118
119impl<T> std::fmt::Debug for SystemSidecar<T> {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct("SystemSidecar")
122            .field("path", &self.path)
123            .finish()
124    }
125}
126
127impl<T> SystemSidecar<T> {
128    /// Construct a sidecar rooted at `<data_path>/_system/<file_name>`.
129    ///
130    /// `file_name` must match the on-disk name the subsystem has always used —
131    /// changing it would orphan existing state across an upgrade.
132    pub fn new(data_path: impl AsRef<Path>, file_name: &str) -> Self {
133        let mut path = data_path.as_ref().to_path_buf();
134        path.push("_system");
135        path.push(file_name);
136        Self {
137            path,
138            _marker: PhantomData,
139        }
140    }
141
142    /// Construct a sidecar at an exact `path`, bypassing the `_system/`
143    /// convention.
144    ///
145    /// For callers that already own the full target path (e.g. an embedder that
146    /// supplies it directly) and must keep that exact location for on-disk
147    /// compatibility.
148    pub fn at_path(path: impl Into<PathBuf>) -> Self {
149        Self {
150            path: path.into(),
151            _marker: PhantomData,
152        }
153    }
154
155    /// Borrow the resolved sidecar path (for diagnostics).
156    pub fn path(&self) -> &Path {
157        &self.path
158    }
159
160    /// Load the persisted document, or `T::default()` if absent or empty.
161    ///
162    /// A missing file and a zero-byte file both mean "nothing persisted yet"
163    /// and yield the default — first-boot callers rely on this.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`SidecarIoError::Read`] on IO failure or
168    /// [`SidecarIoError::Decode`] when the file is present but not valid JSON.
169    pub fn load(&self) -> Result<T, SidecarIoError>
170    where
171        T: DeserializeOwned + Default,
172    {
173        // Read directly: a missing file (`NotFound`) and a zero-byte file both
174        // mean "nothing persisted yet" and yield the default. Reading once
175        // (rather than `exists()` + `read`) avoids a double stat and the race
176        // window where a file deleted between the two calls would surface as a
177        // `Read` error instead of the intended default.
178        let bytes = match std::fs::read(&self.path) {
179            Ok(bytes) => bytes,
180            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
181            Err(source) => {
182                return Err(SidecarIoError::Read {
183                    path: self.path.clone(),
184                    source,
185                });
186            }
187        };
188        if bytes.is_empty() {
189            return Ok(T::default());
190        }
191        serde_json::from_slice(&bytes).map_err(|source| SidecarIoError::Decode {
192            path: self.path.clone(),
193            source,
194        })
195    }
196
197    /// Atomically replace the persisted document with `value`.
198    ///
199    /// Writes a temp file, fsyncs it, renames it over the target, then fsyncs
200    /// the parent directory so the rename itself is crash-durable.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`SidecarIoError::Encode`] if serialization fails,
205    /// [`SidecarIoError::CreateDir`] if the `_system/` directory cannot be
206    /// created, or [`SidecarIoError::Write`] on any write / fsync / rename
207    /// failure.
208    pub fn store(&self, value: &T) -> Result<(), SidecarIoError>
209    where
210        T: Serialize,
211    {
212        self.store_value(value)
213    }
214
215    /// Atomically replace the persisted document with any value that serializes
216    /// to the same JSON as `T`.
217    ///
218    /// Identical to [`Self::store`] but accepts a borrowed view (e.g. a `&[Row]`
219    /// for a `SystemSidecar<Vec<Row>>`) so callers that already own a slice can
220    /// persist it without cloning into an owned `T` first. `S` must serialize to
221    /// the same shape `T` deserializes from — the caller guarantees this.
222    ///
223    /// # Errors
224    ///
225    /// Same as [`Self::store`].
226    pub fn store_value<S>(&self, value: &S) -> Result<(), SidecarIoError>
227    where
228        S: Serialize + ?Sized,
229    {
230        // Resolve the parent dir once: `None` when the path is a bare file name
231        // (or `/`), in which case the sidecar lives in the current directory.
232        let parent = self.path.parent().filter(|p| !p.as_os_str().is_empty());
233        if let Some(parent) = parent {
234            std::fs::create_dir_all(parent).map_err(|source| SidecarIoError::CreateDir {
235                path: parent.to_path_buf(),
236                source,
237            })?;
238        }
239        let json = serde_json::to_vec_pretty(value).map_err(|source| SidecarIoError::Encode {
240            path: self.path.clone(),
241            source,
242        })?;
243        let tmp = self.path.with_extension("tmp");
244        {
245            let mut f = File::create(&tmp).map_err(write_err(&tmp))?;
246            f.write_all(&json).map_err(write_err(&tmp))?;
247            // Flush the file's data before the rename promotes it.
248            f.sync_all().map_err(write_err(&tmp))?;
249        }
250        std::fs::rename(&tmp, &self.path).map_err(write_err(&self.path))?;
251        Self::sync_dir(parent.unwrap_or_else(|| Path::new(".")))?;
252        Ok(())
253    }
254
255    /// Fsync `dir` so a preceding `rename` into it is durable across a crash.
256    ///
257    /// No-op on non-Unix targets, where there is no portable directory-fsync;
258    /// Windows makes the rename durable through different mechanics.
259    fn sync_dir(dir: &Path) -> Result<(), SidecarIoError> {
260        #[cfg(unix)]
261        {
262            let f = File::open(dir).map_err(write_err(dir))?;
263            f.sync_all().map_err(write_err(dir))?;
264        }
265        #[cfg(not(unix))]
266        let _ = dir;
267        Ok(())
268    }
269}
270
271/// Atomic JSON sidecar specialized to a `Vec<T>` document — the shape every
272/// `_system/` row table uses (CDC checkpoints, scheduler jobs, deferred
273/// triggers, declared plugins).
274///
275/// Wraps [`SystemSidecar`] and adds the two conveniences every row-table caller
276/// re-rolled by hand: [`Self::load`] returns an empty `Vec` for an absent /
277/// empty file, and [`Self::store`] takes a borrowed `&[T]` so a caller that
278/// already owns a `Vec<T>` (or a sub-slice) persists it without an intermediate
279/// clone.
280#[derive(Clone, Debug)]
281pub struct VecSidecar<T> {
282    inner: SystemSidecar<Vec<T>>,
283}
284
285impl<T> VecSidecar<T> {
286    /// Construct rooted at `<data_path>/_system/<file_name>`.
287    ///
288    /// `file_name` must match the on-disk name the subsystem has always used —
289    /// changing it would orphan existing state across an upgrade.
290    pub fn new(data_path: impl AsRef<Path>, file_name: &str) -> Self {
291        Self {
292            inner: SystemSidecar::new(data_path, file_name),
293        }
294    }
295
296    /// Borrow the resolved sidecar path (for diagnostics).
297    pub fn path(&self) -> &Path {
298        self.inner.path()
299    }
300
301    /// Load the persisted rows, or an empty `Vec` if the file is absent or empty.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`SidecarIoError::Read`] on IO failure or
306    /// [`SidecarIoError::Decode`] when the file is present but not valid JSON.
307    pub fn load(&self) -> Result<Vec<T>, SidecarIoError>
308    where
309        T: DeserializeOwned,
310    {
311        self.inner.load()
312    }
313
314    /// Atomically replace the persisted rows with `rows`.
315    ///
316    /// Takes a borrowed slice so callers that already own a `Vec<T>` persist it
317    /// without cloning.
318    ///
319    /// # Errors
320    ///
321    /// Same as [`SystemSidecar::store`].
322    pub fn store(&self, rows: &[T]) -> Result<(), SidecarIoError>
323    where
324        T: Serialize,
325    {
326        self.inner.store_value(rows)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn missing_file_loads_default() {
336        let dir = tempfile::tempdir().unwrap();
337        let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "missing.json");
338        assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
339    }
340
341    #[test]
342    fn empty_file_loads_default() {
343        let dir = tempfile::tempdir().unwrap();
344        let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "empty.json");
345        std::fs::create_dir_all(sidecar.path().parent().unwrap()).unwrap();
346        std::fs::write(sidecar.path(), b"").unwrap();
347        assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
348    }
349
350    #[test]
351    fn store_then_load_round_trips() {
352        let dir = tempfile::tempdir().unwrap();
353        let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "rows.json");
354        let rows = vec!["a".to_owned(), "b".to_owned()];
355        sidecar.store(&rows).unwrap();
356        assert_eq!(sidecar.load().unwrap(), rows);
357        // Resolved path follows the `_system/<file_name>` convention.
358        assert!(sidecar.path().ends_with("_system/rows.json"));
359    }
360
361    #[test]
362    fn at_path_uses_exact_location() {
363        let dir = tempfile::tempdir().unwrap();
364        let exact = dir.path().join("declared_plugins.json");
365        let sidecar: SystemSidecar<Vec<u32>> = SystemSidecar::at_path(&exact);
366        sidecar.store(&vec![7]).unwrap();
367        assert_eq!(sidecar.path(), exact);
368        assert!(exact.exists());
369        assert_eq!(sidecar.load().unwrap(), vec![7]);
370    }
371
372    #[test]
373    fn vec_sidecar_stores_borrowed_slice_and_loads_empty_default() {
374        let dir = tempfile::tempdir().unwrap();
375        let sidecar: VecSidecar<String> = VecSidecar::new(dir.path(), "rows.json");
376        // Absent file → empty vec (no `Default` bound needed on the element).
377        assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
378        let rows = vec!["a".to_owned(), "b".to_owned()];
379        // `store` takes `&[T]`; the owned `rows` is untouched (no clone).
380        sidecar.store(&rows).unwrap();
381        assert_eq!(sidecar.load().unwrap(), rows);
382        assert!(sidecar.path().ends_with("_system/rows.json"));
383    }
384
385    #[test]
386    fn store_replaces_previous_and_leaves_no_temp() {
387        let dir = tempfile::tempdir().unwrap();
388        let sidecar: SystemSidecar<Vec<u32>> = SystemSidecar::new(dir.path(), "nums.json");
389        sidecar.store(&vec![1, 2, 3]).unwrap();
390        sidecar.store(&vec![9]).unwrap();
391        assert_eq!(sidecar.load().unwrap(), vec![9]);
392        // The temp file must not survive a successful write.
393        let tmp = sidecar.path().with_extension("tmp");
394        assert!(!tmp.exists(), "temp file leaked: {tmp:?}");
395    }
396}