use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::io::Write;
use std::marker::PhantomData;
use std::path::Path;
pub trait FrameCodec {
type Record;
fn encode(record: &Self::Record) -> Vec<u8>;
fn decode(payload: &[u8]) -> io::Result<Self::Record>;
}
pub struct JsonCodec<T>(PhantomData<T>);
impl<T> FrameCodec for JsonCodec<T>
where T: serde::Serialize + serde::de::DeserializeOwned
{
type Record = T;
fn encode(record: &T) -> Vec<u8> {
serde_json::to_vec(record).expect("journal record serializes to JSON")
}
fn decode(payload: &[u8]) -> io::Result<T> {
serde_json::from_slice(payload)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
}
#[derive(Debug)]
pub struct JournalWriter {
file: Option<File>,
parent_unsynced: bool,
}
impl JournalWriter {
pub fn new(needs_parent_sync: bool) -> Self {
Self {
file: None,
parent_unsynced: needs_parent_sync,
}
}
pub fn append<C: FrameCodec>(&mut self, path: &Path, record: &C::Record) -> io::Result<()> {
let payload = C::encode(record);
let len = u32::try_from(payload.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "journal record too large"))?;
let file = self.file_mut(path)?;
file.write_all(&len.to_le_bytes())?;
file.write_all(&payload)
}
pub fn sync(&mut self, path: &Path) -> io::Result<()> {
let file = self.file.as_mut().expect("file opened before sync");
file.sync_data()?;
if self.parent_unsynced
&& let Some(parent) = path.parent()
&& let Ok(dir) = File::open(parent)
{
dir.sync_all()?;
self.parent_unsynced = false;
}
Ok(())
}
fn file_mut(&mut self, path: &Path) -> io::Result<&mut File> {
if self.file.is_none() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
self.file = Some(OpenOptions::new().create(true).append(true).open(path)?);
}
Ok(self.file.as_mut().expect("file opened above"))
}
}
pub fn replay<C: FrameCodec>(path: &Path) -> io::Result<Vec<C::Record>> {
if !path.exists() {
return Ok(Vec::new());
}
let bytes = fs::read(path)?;
let (records, valid_len) = decode_frames::<C>(&bytes)?;
if valid_len < bytes.len() {
truncate_to(path, valid_len)?;
}
Ok(records)
}
pub fn decode_frames<C: FrameCodec>(bytes: &[u8]) -> io::Result<(Vec<C::Record>, usize)> {
let mut records = Vec::new();
let mut offset = 0usize;
while offset < bytes.len() {
let Some(len_bytes) = bytes.get(offset..offset.saturating_add(4)) else {
return Ok((records, offset)); };
let len = usize::try_from(u32::from_le_bytes(
len_bytes.try_into().expect("slice is exactly four bytes"),
))
.expect("u32 fits usize");
let start = offset.saturating_add(4);
let end = start.checked_add(len).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "journal frame length overflow")
})?;
let Some(payload) = bytes.get(start..end) else {
return Ok((records, offset)); };
records.push(C::decode(payload)?);
offset = end;
}
Ok((records, bytes.len()))
}
pub fn truncate_to(path: &Path, valid_len: usize) -> io::Result<()> {
let file = OpenOptions::new().write(true).open(path)?;
file.set_len(u64::try_from(valid_len).expect("valid frame offset fits u64"))?;
file.sync_data()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_all(path: &Path, records: &[String]) {
let mut writer = JournalWriter::new(true);
for record in records {
writer
.append::<JsonCodec<String>>(path, record)
.expect("append record");
}
writer.sync(path).expect("sync journal");
}
#[test]
fn replays_appended_records_in_order() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("journal");
let records = vec!["a".to_owned(), "bb".to_owned(), "ccc".to_owned()];
write_all(&path, &records);
let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
assert_eq!(replayed, records);
}
#[test]
fn replay_of_missing_file_is_empty() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("absent");
let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
assert!(replayed.is_empty());
}
#[test]
fn append_reopens_and_extends_existing_journal() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("journal");
write_all(&path, &["first".to_owned()]);
write_all(&path, &["second".to_owned()]);
let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
assert_eq!(replayed, vec!["first".to_owned(), "second".to_owned()]);
}
#[test]
fn replay_truncates_a_torn_trailing_frame() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("journal");
write_all(&path, &["clean".to_owned()]);
let mut file = OpenOptions::new().append(true).open(&path).expect("reopen");
file.write_all(&64_u32.to_le_bytes()).expect("torn length");
file.write_all(b"torn").expect("torn payload");
file.sync_data().expect("sync torn tail");
let torn_len = fs::metadata(&path).expect("metadata").len();
let replayed = replay::<JsonCodec<String>>(&path).expect("replay");
assert_eq!(replayed, vec!["clean".to_owned()]);
let healed_len = fs::metadata(&path).expect("metadata").len();
assert!(healed_len < torn_len);
let reread = replay::<JsonCodec<String>>(&path).expect("re-replay");
assert_eq!(reread, vec!["clean".to_owned()]);
}
}