ursula_runtime/
journal.rs1use 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
18pub trait FrameCodec {
24 type Record;
26
27 fn encode(record: &Self::Record) -> Vec<u8>;
29
30 fn decode(payload: &[u8]) -> io::Result<Self::Record>;
32}
33
34pub 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#[derive(Debug)]
58pub struct JournalWriter {
59 file: Option<File>,
60 parent_unsynced: bool,
61}
62
63impl JournalWriter {
64 pub fn new(needs_parent_sync: bool) -> Self {
67 Self {
68 file: None,
69 parent_unsynced: needs_parent_sync,
70 }
71 }
72
73 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 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
109pub 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
124pub 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)); };
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)); };
144 records.push(C::decode(payload)?);
145 offset = end;
146 }
147 Ok((records, bytes.len()))
148}
149
150pub 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 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 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}