ursula_runtime/
journal.rs1use 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
19pub trait FrameCodec {
25 type Record;
27
28 fn encode(record: &Self::Record) -> Vec<u8>;
30
31 fn decode(payload: &[u8]) -> io::Result<Self::Record>;
33}
34
35pub 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#[derive(Debug)]
59pub struct JournalWriter {
60 file: Option<File>,
61 parent_unsynced: bool,
62}
63
64impl JournalWriter {
65 pub fn new(needs_parent_sync: bool) -> Self {
68 Self {
69 file: None,
70 parent_unsynced: needs_parent_sync,
71 }
72 }
73
74 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 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
110pub 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
122pub 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
169pub 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)); };
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)); };
189 records.push(C::decode(payload)?);
190 offset = end;
191 }
192 Ok((records, bytes.len()))
193}
194
195pub 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 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 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}