Skip to main content

ursula_runtime/
journal.rs

1//! Append-only framed journal.
2//!
3//! Persistence is kept orthogonal to serialization. The journal moves opaque
4//! `[u32-LE length][payload]` frames to and from a file and handles the durability
5//! concerns — append, `fsync`, and recovery of a torn trailing frame after a crash.
6//! How a record turns into a payload is entirely the [`FrameCodec`]'s business, so
7//! the Raft log store can frame protobuf while the WAL engine frames JSON over the
8//! exact same code.
9
10use std::fs;
11use std::fs::File;
12use std::fs::OpenOptions;
13use std::io;
14use std::io::Write;
15use std::marker::PhantomData;
16use std::path::Path;
17
18/// Serialization seam: how one record becomes a frame payload and back.
19///
20/// `encode` is infallible because the codecs we use (protobuf, JSON over plain
21/// owned types) cannot fail in practice; a codec with fallible encoding should
22/// surface that as an `io::Error` from a panic-documented invariant instead.
23pub trait FrameCodec {
24    /// The record type carried in each frame.
25    type Record;
26
27    /// Serialize a record into a frame payload.
28    fn encode(record: &Self::Record) -> Vec<u8>;
29
30    /// Deserialize a frame payload back into a record.
31    fn decode(payload: &[u8]) -> io::Result<Self::Record>;
32}
33
34/// JSON frame codec for any owned, serde-serializable record.
35pub struct JsonCodec<T>(PhantomData<T>);
36
37impl<T> FrameCodec for JsonCodec<T>
38where T: serde::Serialize + serde::de::DeserializeOwned
39{
40    type Record = T;
41
42    fn encode(record: &T) -> Vec<u8> {
43        serde_json::to_vec(record).expect("journal record serializes to JSON")
44    }
45
46    fn decode(payload: &[u8]) -> io::Result<T> {
47        serde_json::from_slice(payload)
48            .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
49    }
50}
51
52/// An append handle over a single journal file.
53///
54/// The file is opened lazily on first append. The parent directory is `fsync`ed
55/// once on the first [`JournalWriter::sync`] when the file may have been freshly
56/// created, so the file's existence survives a crash.
57#[derive(Debug)]
58pub struct JournalWriter {
59    file: Option<File>,
60    parent_unsynced: bool,
61}
62
63impl JournalWriter {
64    /// Create a writer. Set `needs_parent_sync` when the file may not exist yet, so
65    /// the parent directory is `fsync`ed once the file is created.
66    pub fn new(needs_parent_sync: bool) -> Self {
67        Self {
68            file: None,
69            parent_unsynced: needs_parent_sync,
70        }
71    }
72
73    /// Append one record as a framed payload. Does not durably flush; pair with
74    /// [`JournalWriter::sync`] once per batch.
75    pub fn append<C: FrameCodec>(&mut self, path: &Path, record: &C::Record) -> io::Result<()> {
76        let payload = C::encode(record);
77        let len = u32::try_from(payload.len())
78            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "journal record too large"))?;
79        let file = self.file_mut(path)?;
80        file.write_all(&len.to_le_bytes())?;
81        file.write_all(&payload)
82    }
83
84    /// `fsync` the file data, plus the parent directory once if it was freshly created.
85    pub fn sync(&mut self, path: &Path) -> io::Result<()> {
86        let file = self.file.as_mut().expect("file opened before sync");
87        file.sync_data()?;
88        if self.parent_unsynced
89            && let Some(parent) = path.parent()
90            && let Ok(dir) = File::open(parent)
91        {
92            dir.sync_all()?;
93            self.parent_unsynced = false;
94        }
95        Ok(())
96    }
97
98    fn file_mut(&mut self, path: &Path) -> io::Result<&mut File> {
99        if self.file.is_none() {
100            if let Some(parent) = path.parent() {
101                fs::create_dir_all(parent)?;
102            }
103            self.file = Some(OpenOptions::new().create(true).append(true).open(path)?);
104        }
105        Ok(self.file.as_mut().expect("file opened above"))
106    }
107}
108
109/// Read every record from `path`, decoding with `C`. A torn trailing frame left by a
110/// crash mid-write is truncated away and ignored, leaving the file at its last clean
111/// record boundary.
112pub fn replay<C: FrameCodec>(path: &Path) -> io::Result<Vec<C::Record>> {
113    if !path.exists() {
114        return Ok(Vec::new());
115    }
116    let bytes = fs::read(path)?;
117    let (records, valid_len) = decode_frames::<C>(&bytes)?;
118    if valid_len < bytes.len() {
119        truncate_to(path, valid_len)?;
120    }
121    Ok(records)
122}
123
124/// Decode framed records from an in-memory buffer, returning the records and the byte
125/// length of the valid (fully-written) prefix. A torn trailing frame ends the scan.
126pub fn decode_frames<C: FrameCodec>(bytes: &[u8]) -> io::Result<(Vec<C::Record>, usize)> {
127    let mut records = Vec::new();
128    let mut offset = 0usize;
129    while offset < bytes.len() {
130        let Some(len_bytes) = bytes.get(offset..offset.saturating_add(4)) else {
131            return Ok((records, offset)); // torn length prefix
132        };
133        let len = usize::try_from(u32::from_le_bytes(
134            len_bytes.try_into().expect("slice is exactly four bytes"),
135        ))
136        .expect("u32 fits usize");
137        let start = offset.saturating_add(4);
138        let end = start.checked_add(len).ok_or_else(|| {
139            io::Error::new(io::ErrorKind::InvalidData, "journal frame length overflow")
140        })?;
141        let Some(payload) = bytes.get(start..end) else {
142            return Ok((records, offset)); // torn payload
143        };
144        records.push(C::decode(payload)?);
145        offset = end;
146    }
147    Ok((records, bytes.len()))
148}
149
150/// Truncate `path` to `valid_len` bytes, dropping a torn trailing frame, then `fsync`.
151pub fn truncate_to(path: &Path, valid_len: usize) -> io::Result<()> {
152    let file = OpenOptions::new().write(true).open(path)?;
153    file.set_len(u64::try_from(valid_len).expect("valid frame offset fits u64"))?;
154    file.sync_data()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn write_all(path: &Path, records: &[String]) {
162        let mut writer = JournalWriter::new(true);
163        for record in records {
164            writer
165                .append::<JsonCodec<String>>(path, record)
166                .expect("append record");
167        }
168        writer.sync(path).expect("sync journal");
169    }
170
171    #[test]
172    fn replays_appended_records_in_order() {
173        let dir = tempfile::tempdir().expect("temp dir");
174        let path = dir.path().join("journal");
175        let records = vec!["a".to_owned(), "bb".to_owned(), "ccc".to_owned()];
176        write_all(&path, &records);
177
178        let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
179        assert_eq!(replayed, records);
180    }
181
182    #[test]
183    fn replay_of_missing_file_is_empty() {
184        let dir = tempfile::tempdir().expect("temp dir");
185        let path = dir.path().join("absent");
186        let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
187        assert!(replayed.is_empty());
188    }
189
190    #[test]
191    fn append_reopens_and_extends_existing_journal() {
192        let dir = tempfile::tempdir().expect("temp dir");
193        let path = dir.path().join("journal");
194        write_all(&path, &["first".to_owned()]);
195        write_all(&path, &["second".to_owned()]);
196
197        let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
198        assert_eq!(replayed, vec!["first".to_owned(), "second".to_owned()]);
199    }
200
201    #[test]
202    fn replay_truncates_a_torn_trailing_frame() {
203        let dir = tempfile::tempdir().expect("temp dir");
204        let path = dir.path().join("journal");
205        write_all(&path, &["clean".to_owned()]);
206
207        // Append a frame whose length header promises more bytes than follow.
208        let mut file = OpenOptions::new().append(true).open(&path).expect("reopen");
209        file.write_all(&64_u32.to_le_bytes()).expect("torn length");
210        file.write_all(b"torn").expect("torn payload");
211        file.sync_data().expect("sync torn tail");
212        let torn_len = fs::metadata(&path).expect("metadata").len();
213
214        let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
215        assert_eq!(replayed, vec!["clean".to_owned()]);
216
217        // The torn tail was truncated away, so a re-read is clean and shorter.
218        let healed_len = fs::metadata(&path).expect("metadata").len();
219        assert!(healed_len < torn_len);
220        let reread = replay::<JsonCodec<String>>(&path).expect("re-replay");
221        assert_eq!(reread, vec!["clean".to_owned()]);
222    }
223}