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