1use std::fs::{File, OpenOptions};
31use std::io::{Read, Write};
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::{Error, Result};
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum VoteScope {
42 Height(u32),
44 Burn(String),
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum VoteRole {
52 Proposed,
54 Signed,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum VoteStage {
62 Intent,
65 #[default]
67 Signed,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct VoteEntry {
75 pub scope: VoteScope,
77 pub role: VoteRole,
79 pub subject: String,
81 pub digest: String,
85 pub at: u64,
89 #[serde(default)]
92 pub stage: VoteStage,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub signature: Option<String>,
98}
99
100pub trait VoteJournal {
103 fn record(&mut self, entry: &VoteEntry) -> Result<()>;
106 fn entries(&self) -> Result<Vec<VoteEntry>>;
108}
109
110#[derive(Debug, Default)]
112pub struct MemoryJournal {
113 entries: Vec<VoteEntry>,
114}
115
116impl MemoryJournal {
117 pub fn new() -> Self {
119 Self::default()
120 }
121 pub fn with_entries(entries: Vec<VoteEntry>) -> Self {
123 Self { entries }
124 }
125}
126
127impl VoteJournal for MemoryJournal {
128 fn record(&mut self, entry: &VoteEntry) -> Result<()> {
129 self.entries.push(entry.clone());
130 Ok(())
131 }
132 fn entries(&self) -> Result<Vec<VoteEntry>> {
133 Ok(self.entries.clone())
134 }
135}
136
137#[derive(Debug)]
164pub struct FileJournal {
165 path: PathBuf,
166 file: File,
167 durable_len: u64,
170 poisoned: Option<String>,
172}
173
174struct Parsed {
177 entries: Vec<VoteEntry>,
178 durable_len: u64,
179 torn: Option<(u64, Option<VoteEntry>)>,
180}
181
182fn parse(bytes: &[u8], path: &Path) -> Result<Parsed> {
183 let mut entries = Vec::new();
184 let mut pos = 0usize;
185 let mut line_no = 0usize;
186 let mut durable_len = 0u64;
187 let mut torn = None;
188 while pos < bytes.len() {
189 line_no += 1;
190 let rest = &bytes[pos..];
191 match rest.iter().position(|b| *b == b'\n') {
192 Some(nl) => {
193 let line = &rest[..nl];
194 let text = std::str::from_utf8(line).map_err(|e| {
195 Error::Journal(format!("{} line {line_no}: {e}", path.display()))
196 })?;
197 if !text.trim().is_empty() {
198 let e = serde_json::from_str::<VoteEntry>(text).map_err(|e| {
199 Error::Journal(format!("{} line {line_no}: {e}", path.display()))
200 })?;
201 entries.push(e);
202 }
203 pos += nl + 1;
204 durable_len = pos as u64;
205 }
206 None => {
207 let whole = std::str::from_utf8(rest)
208 .ok()
209 .and_then(|t| serde_json::from_str::<VoteEntry>(t).ok());
210 torn = Some((pos as u64, whole));
211 break;
212 }
213 }
214 }
215 Ok(Parsed {
216 entries,
217 durable_len,
218 torn,
219 })
220}
221
222fn journal_err(path: &Path, what: &str, e: impl std::fmt::Display) -> Error {
223 Error::Journal(format!("{}: {what}: {e}", path.display()))
224}
225
226fn lock_exclusive(file: &File, path: &Path) -> Result<()> {
231 use rustix::fs::{flock, FlockOperation};
232 flock(file, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
233 if e == rustix::io::Errno::WOULDBLOCK {
234 Error::Journal(format!(
235 "{}: held by another handle; one writer per journal file",
236 path.display()
237 ))
238 } else {
239 journal_err(path, "lock", e)
240 }
241 })
242}
243
244fn sync_dir(path: &Path) -> Result<()> {
245 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
246 File::open(dir)
247 .and_then(|d| d.sync_all())
248 .map_err(|e| journal_err(dir, "fsync directory", e))?;
249 }
250 Ok(())
251}
252
253impl FileJournal {
254 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
257 let path = path.as_ref().to_path_buf();
258 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
259 std::fs::create_dir_all(dir)?;
260 }
261 let existed = path.exists();
262 let mut file = OpenOptions::new()
263 .create(true)
264 .append(true)
265 .read(true)
266 .open(&path)
267 .map_err(|e| journal_err(&path, "open", e))?;
268 lock_exclusive(&file, &path)?;
269 if !existed {
270 file.sync_all()
271 .map_err(|e| journal_err(&path, "fsync", e))?;
272 sync_dir(&path)?;
273 }
274 let mut bytes = Vec::new();
275 file.read_to_end(&mut bytes)
276 .map_err(|e| journal_err(&path, "read", e))?;
277 let parsed = parse(&bytes, &path)?;
278 let mut durable_len = parsed.durable_len;
279 if let Some((start, whole)) = parsed.torn {
280 match whole {
281 Some(_) => {
282 file.write_all(b"\n")
284 .map_err(|e| journal_err(&path, "repair", e))?;
285 durable_len = bytes.len() as u64 + 1;
286 }
287 None => {
288 file.set_len(start)
289 .map_err(|e| journal_err(&path, "truncate torn tail", e))?;
290 durable_len = start;
291 }
292 }
293 file.sync_all()
294 .map_err(|e| journal_err(&path, "fsync repair", e))?;
295 sync_dir(&path)?;
296 }
297 Ok(Self {
298 path,
299 file,
300 durable_len,
301 poisoned: None,
302 })
303 }
304 pub fn path(&self) -> &Path {
306 &self.path
307 }
308}
309
310impl VoteJournal for FileJournal {
311 fn record(&mut self, entry: &VoteEntry) -> Result<()> {
312 if let Some(why) = &self.poisoned {
313 return Err(Error::Journal(format!(
314 "{}: refusing every write after a failed append: {why}",
315 self.path.display()
316 )));
317 }
318 let mut line = serde_json::to_string(entry).map_err(|e| Error::Journal(e.to_string()))?;
319 line.push('\n');
320 let before = self
325 .file
326 .metadata()
327 .map(|m| m.len())
328 .map_err(|e| journal_err(&self.path, "stat before append", e))?;
329 let written = self
330 .file
331 .write_all(line.as_bytes())
332 .and_then(|()| self.file.sync_data());
333 match written {
334 Ok(()) => {
335 self.durable_len = before + line.len() as u64;
336 Ok(())
337 }
338 Err(e) => {
339 let rolled = self
341 .file
342 .set_len(before)
343 .and_then(|()| self.file.sync_data());
344 if let Err(r) = rolled {
345 self.poisoned = Some(format!("{e}; rollback failed: {r}"));
346 }
347 Err(journal_err(&self.path, "append", e))
348 }
349 }
350 }
351
352 fn entries(&self) -> Result<Vec<VoteEntry>> {
353 if let Some(why) = &self.poisoned {
354 return Err(Error::Journal(format!(
355 "{}: unreadable after a failed append: {why}",
356 self.path.display()
357 )));
358 }
359 let bytes = std::fs::read(&self.path).map_err(|e| journal_err(&self.path, "read", e))?;
360 let parsed = parse(&bytes, &self.path)?;
361 if let Some((start, _)) = parsed.torn {
362 return Err(Error::Journal(format!(
363 "{}: unterminated record at byte {start}; reopen the journal to repair it",
364 self.path.display()
365 )));
366 }
367 Ok(parsed.entries)
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn entry(h: u32) -> VoteEntry {
376 VoteEntry {
377 scope: VoteScope::Height(h),
378 role: VoteRole::Signed,
379 subject: "ab".repeat(32),
380 digest: "cd".repeat(32),
381 at: 1_790_000_000_000 + u64::from(h),
382 stage: VoteStage::Signed,
383 signature: None,
384 }
385 }
386
387 fn scratch(name: &str) -> PathBuf {
388 std::env::temp_dir().join(format!(
389 "sidestr-round-journal-{name}-{}",
390 std::process::id()
391 ))
392 }
393
394 #[test]
395 fn the_file_journal_round_trips_and_repairs_a_torn_tail_before_appending() {
396 let dir = scratch("torn");
397 let _ = std::fs::remove_dir_all(&dir);
398 let path = dir.join("votes.jsonl");
399 {
400 let mut j = FileJournal::open(&path).unwrap();
401 j.record(&entry(1)).unwrap();
402 j.record(&VoteEntry {
403 scope: VoteScope::Burn(format!("{}:0", "ef".repeat(32))),
404 role: VoteRole::Proposed,
405 ..entry(2)
406 })
407 .unwrap();
408 assert_eq!(j.entries().unwrap().len(), 2);
409 }
410 let clean_len = std::fs::metadata(&path).unwrap().len();
411 std::fs::OpenOptions::new()
413 .append(true)
414 .open(&path)
415 .unwrap()
416 .write_all(b"{\"scope\":{\"hei")
417 .unwrap();
418 let mut j = FileJournal::open(&path).unwrap();
419 assert_eq!(std::fs::metadata(&path).unwrap().len(), clean_len);
420 let e = j.entries().unwrap();
421 assert_eq!(e.len(), 2);
422 assert_eq!(e[0], entry(1));
423 assert!(matches!(e[1].scope, VoteScope::Burn(_)));
424 j.record(&entry(3)).unwrap();
425 drop(j);
426 let e = FileJournal::open(&path).unwrap().entries().unwrap();
427 assert_eq!(e.len(), 3, "append after recovery survives a reload");
428 assert_eq!(e[2], entry(3));
429 std::fs::OpenOptions::new()
431 .append(true)
432 .open(&path)
433 .unwrap()
434 .write_all(b"{\"sco")
435 .unwrap();
436 let mut j = FileJournal::open(&path).unwrap();
437 j.record(&entry(4)).unwrap();
438 drop(j);
439 let e = FileJournal::open(&path).unwrap().entries().unwrap();
440 assert_eq!(e.iter().map(|e| &e.scope).collect::<Vec<_>>().len(), 4);
441 assert_eq!(e[3], entry(4));
442 let mut whole = serde_json::to_vec(&entry(5)).unwrap();
444 std::fs::OpenOptions::new()
445 .append(true)
446 .open(&path)
447 .unwrap()
448 .write_all(&whole)
449 .unwrap();
450 let j = FileJournal::open(&path).unwrap();
451 assert_eq!(j.entries().unwrap().len(), 5);
452 whole.push(b'\n');
453 assert!(std::fs::read(&path).unwrap().ends_with(&whole));
454 drop(j); let mut text = std::fs::read_to_string(&path).unwrap();
457 text.push_str("{\"scope\":{\"height\":3}}\n");
458 std::fs::write(&path, text).unwrap();
459 let e = FileJournal::open(&path).unwrap_err().to_string();
460 assert!(e.contains("line 6"), "{e}");
461 let _ = std::fs::remove_dir_all(dir);
462 }
463
464 #[test]
465 fn a_fresh_file_and_a_reload_read_older_records_without_a_stage() {
466 let dir = scratch("stage");
467 let _ = std::fs::remove_dir_all(&dir);
468 let path = dir.join("deep").join("votes.jsonl");
469 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
470 std::fs::write(
471 &path,
472 format!(
473 "{{\"scope\":{{\"height\":9}},\"role\":\"signed\",\"subject\":\"{}\",\"digest\":\"{}\",\"at\":5}}\n",
474 "ab".repeat(32),
475 "cd".repeat(32)
476 ),
477 )
478 .unwrap();
479 let j = FileJournal::open(&path).unwrap();
480 let e = j.entries().unwrap();
481 assert_eq!(e[0].stage, VoteStage::Signed);
482 assert_eq!(e[0].signature, None);
483 let _ = std::fs::remove_dir_all(dir);
484 }
485
486 #[test]
487 fn the_wire_shape_of_an_entry_is_stable() {
488 let s = serde_json::to_string(&entry(5)).unwrap();
489 assert!(
490 s.starts_with(r#"{"scope":{"height":5},"role":"signed","subject":""#),
491 "{s}"
492 );
493 assert!(s.ends_with(r#","stage":"signed"}"#), "{s}");
494 let s = serde_json::to_string(&VoteEntry {
495 stage: VoteStage::Intent,
496 ..entry(5)
497 })
498 .unwrap();
499 assert!(s.ends_with(r#","stage":"intent"}"#), "{s}");
500 }
501}