use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoteScope {
Height(u32),
Burn(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoteRole {
Proposed,
Signed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoteStage {
Intent,
#[default]
Signed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VoteEntry {
pub scope: VoteScope,
pub role: VoteRole,
pub subject: String,
pub digest: String,
pub at: u64,
#[serde(default)]
pub stage: VoteStage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
pub trait VoteJournal {
fn record(&mut self, entry: &VoteEntry) -> Result<()>;
fn entries(&self) -> Result<Vec<VoteEntry>>;
}
#[derive(Debug, Default)]
pub struct MemoryJournal {
entries: Vec<VoteEntry>,
}
impl MemoryJournal {
pub fn new() -> Self {
Self::default()
}
pub fn with_entries(entries: Vec<VoteEntry>) -> Self {
Self { entries }
}
}
impl VoteJournal for MemoryJournal {
fn record(&mut self, entry: &VoteEntry) -> Result<()> {
self.entries.push(entry.clone());
Ok(())
}
fn entries(&self) -> Result<Vec<VoteEntry>> {
Ok(self.entries.clone())
}
}
#[derive(Debug)]
pub struct FileJournal {
path: PathBuf,
file: File,
durable_len: u64,
poisoned: Option<String>,
}
struct Parsed {
entries: Vec<VoteEntry>,
durable_len: u64,
torn: Option<(u64, Option<VoteEntry>)>,
}
fn parse(bytes: &[u8], path: &Path) -> Result<Parsed> {
let mut entries = Vec::new();
let mut pos = 0usize;
let mut line_no = 0usize;
let mut durable_len = 0u64;
let mut torn = None;
while pos < bytes.len() {
line_no += 1;
let rest = &bytes[pos..];
match rest.iter().position(|b| *b == b'\n') {
Some(nl) => {
let line = &rest[..nl];
let text = std::str::from_utf8(line).map_err(|e| {
Error::Journal(format!("{} line {line_no}: {e}", path.display()))
})?;
if !text.trim().is_empty() {
let e = serde_json::from_str::<VoteEntry>(text).map_err(|e| {
Error::Journal(format!("{} line {line_no}: {e}", path.display()))
})?;
entries.push(e);
}
pos += nl + 1;
durable_len = pos as u64;
}
None => {
let whole = std::str::from_utf8(rest)
.ok()
.and_then(|t| serde_json::from_str::<VoteEntry>(t).ok());
torn = Some((pos as u64, whole));
break;
}
}
}
Ok(Parsed {
entries,
durable_len,
torn,
})
}
fn journal_err(path: &Path, what: &str, e: impl std::fmt::Display) -> Error {
Error::Journal(format!("{}: {what}: {e}", path.display()))
}
fn lock_exclusive(file: &File, path: &Path) -> Result<()> {
use rustix::fs::{flock, FlockOperation};
flock(file, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
if e == rustix::io::Errno::WOULDBLOCK {
Error::Journal(format!(
"{}: held by another handle; one writer per journal file",
path.display()
))
} else {
journal_err(path, "lock", e)
}
})
}
fn sync_dir(path: &Path) -> Result<()> {
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
File::open(dir)
.and_then(|d| d.sync_all())
.map_err(|e| journal_err(dir, "fsync directory", e))?;
}
Ok(())
}
impl FileJournal {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir)?;
}
let existed = path.exists();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(&path)
.map_err(|e| journal_err(&path, "open", e))?;
lock_exclusive(&file, &path)?;
if !existed {
file.sync_all()
.map_err(|e| journal_err(&path, "fsync", e))?;
sync_dir(&path)?;
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|e| journal_err(&path, "read", e))?;
let parsed = parse(&bytes, &path)?;
let mut durable_len = parsed.durable_len;
if let Some((start, whole)) = parsed.torn {
match whole {
Some(_) => {
file.write_all(b"\n")
.map_err(|e| journal_err(&path, "repair", e))?;
durable_len = bytes.len() as u64 + 1;
}
None => {
file.set_len(start)
.map_err(|e| journal_err(&path, "truncate torn tail", e))?;
durable_len = start;
}
}
file.sync_all()
.map_err(|e| journal_err(&path, "fsync repair", e))?;
sync_dir(&path)?;
}
Ok(Self {
path,
file,
durable_len,
poisoned: None,
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl VoteJournal for FileJournal {
fn record(&mut self, entry: &VoteEntry) -> Result<()> {
if let Some(why) = &self.poisoned {
return Err(Error::Journal(format!(
"{}: refusing every write after a failed append: {why}",
self.path.display()
)));
}
let mut line = serde_json::to_string(entry).map_err(|e| Error::Journal(e.to_string()))?;
line.push('\n');
let before = self
.file
.metadata()
.map(|m| m.len())
.map_err(|e| journal_err(&self.path, "stat before append", e))?;
let written = self
.file
.write_all(line.as_bytes())
.and_then(|()| self.file.sync_data());
match written {
Ok(()) => {
self.durable_len = before + line.len() as u64;
Ok(())
}
Err(e) => {
let rolled = self
.file
.set_len(before)
.and_then(|()| self.file.sync_data());
if let Err(r) = rolled {
self.poisoned = Some(format!("{e}; rollback failed: {r}"));
}
Err(journal_err(&self.path, "append", e))
}
}
}
fn entries(&self) -> Result<Vec<VoteEntry>> {
if let Some(why) = &self.poisoned {
return Err(Error::Journal(format!(
"{}: unreadable after a failed append: {why}",
self.path.display()
)));
}
let bytes = std::fs::read(&self.path).map_err(|e| journal_err(&self.path, "read", e))?;
let parsed = parse(&bytes, &self.path)?;
if let Some((start, _)) = parsed.torn {
return Err(Error::Journal(format!(
"{}: unterminated record at byte {start}; reopen the journal to repair it",
self.path.display()
)));
}
Ok(parsed.entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(h: u32) -> VoteEntry {
VoteEntry {
scope: VoteScope::Height(h),
role: VoteRole::Signed,
subject: "ab".repeat(32),
digest: "cd".repeat(32),
at: 1_790_000_000_000 + u64::from(h),
stage: VoteStage::Signed,
signature: None,
}
}
fn scratch(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"sidestr-round-journal-{name}-{}",
std::process::id()
))
}
#[test]
fn the_file_journal_round_trips_and_repairs_a_torn_tail_before_appending() {
let dir = scratch("torn");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("votes.jsonl");
{
let mut j = FileJournal::open(&path).unwrap();
j.record(&entry(1)).unwrap();
j.record(&VoteEntry {
scope: VoteScope::Burn(format!("{}:0", "ef".repeat(32))),
role: VoteRole::Proposed,
..entry(2)
})
.unwrap();
assert_eq!(j.entries().unwrap().len(), 2);
}
let clean_len = std::fs::metadata(&path).unwrap().len();
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap()
.write_all(b"{\"scope\":{\"hei")
.unwrap();
let mut j = FileJournal::open(&path).unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), clean_len);
let e = j.entries().unwrap();
assert_eq!(e.len(), 2);
assert_eq!(e[0], entry(1));
assert!(matches!(e[1].scope, VoteScope::Burn(_)));
j.record(&entry(3)).unwrap();
drop(j);
let e = FileJournal::open(&path).unwrap().entries().unwrap();
assert_eq!(e.len(), 3, "append after recovery survives a reload");
assert_eq!(e[2], entry(3));
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap()
.write_all(b"{\"sco")
.unwrap();
let mut j = FileJournal::open(&path).unwrap();
j.record(&entry(4)).unwrap();
drop(j);
let e = FileJournal::open(&path).unwrap().entries().unwrap();
assert_eq!(e.iter().map(|e| &e.scope).collect::<Vec<_>>().len(), 4);
assert_eq!(e[3], entry(4));
let mut whole = serde_json::to_vec(&entry(5)).unwrap();
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap()
.write_all(&whole)
.unwrap();
let j = FileJournal::open(&path).unwrap();
assert_eq!(j.entries().unwrap().len(), 5);
whole.push(b'\n');
assert!(std::fs::read(&path).unwrap().ends_with(&whole));
drop(j); let mut text = std::fs::read_to_string(&path).unwrap();
text.push_str("{\"scope\":{\"height\":3}}\n");
std::fs::write(&path, text).unwrap();
let e = FileJournal::open(&path).unwrap_err().to_string();
assert!(e.contains("line 6"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_fresh_file_and_a_reload_read_older_records_without_a_stage() {
let dir = scratch("stage");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("deep").join("votes.jsonl");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
format!(
"{{\"scope\":{{\"height\":9}},\"role\":\"signed\",\"subject\":\"{}\",\"digest\":\"{}\",\"at\":5}}\n",
"ab".repeat(32),
"cd".repeat(32)
),
)
.unwrap();
let j = FileJournal::open(&path).unwrap();
let e = j.entries().unwrap();
assert_eq!(e[0].stage, VoteStage::Signed);
assert_eq!(e[0].signature, None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_wire_shape_of_an_entry_is_stable() {
let s = serde_json::to_string(&entry(5)).unwrap();
assert!(
s.starts_with(r#"{"scope":{"height":5},"role":"signed","subject":""#),
"{s}"
);
assert!(s.ends_with(r#","stage":"signed"}"#), "{s}");
let s = serde_json::to_string(&VoteEntry {
stage: VoteStage::Intent,
..entry(5)
})
.unwrap();
assert!(s.ends_with(r#","stage":"intent"}"#), "{s}");
}
}