use std::{
fs::{File, OpenOptions},
io::{self, Read, Seek, SeekFrom, Write},
path::Path,
};
use data::LinkReference;
use super::{JournalEntry, Transition, TransitionKind};
use crate::Link;
pub trait TransitionLog<T: LinkReference>: Send + Sync {
fn read_entries(&mut self) -> io::Result<Vec<JournalEntry<T>>>;
fn append(&mut self, entry: &JournalEntry<T>) -> io::Result<()>;
}
#[derive(Debug, Clone, Default)]
pub struct MemoryTransitionLog<T: LinkReference> {
entries: Vec<JournalEntry<T>>,
}
impl<T: LinkReference> MemoryTransitionLog<T> {
#[must_use]
pub fn entries(&self) -> &[JournalEntry<T>] {
&self.entries
}
}
impl<T: LinkReference> TransitionLog<T> for MemoryTransitionLog<T> {
fn read_entries(&mut self) -> io::Result<Vec<JournalEntry<T>>> {
Ok(self.entries.clone())
}
fn append(&mut self, entry: &JournalEntry<T>) -> io::Result<()> {
self.entries.push(entry.clone());
Ok(())
}
}
#[derive(Debug)]
pub struct FileTransitionLog<T: LinkReference> {
file: File,
marker: std::marker::PhantomData<T>,
}
impl<T: LinkReference> FileTransitionLog<T> {
pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
let mut log = Self {
file,
marker: std::marker::PhantomData,
};
log.read_entries()?;
Ok(log)
}
}
impl<T: LinkReference> TransitionLog<T> for FileTransitionLog<T> {
fn read_entries(&mut self) -> io::Result<Vec<JournalEntry<T>>> {
self.file.seek(SeekFrom::Start(0))?;
let mut bytes = Vec::new();
self.file.read_to_end(&mut bytes)?;
let complete = bytes
.iter()
.rposition(|&byte| byte == b'\n')
.map_or(0, |i| i + 1);
let contents = std::str::from_utf8(&bytes[..complete]).map_err(invalid)?;
let entries = contents
.lines()
.map(decode)
.collect::<io::Result<Vec<_>>>()?;
if complete != bytes.len() {
self.file
.set_len(u64::try_from(complete).map_err(invalid)?)?;
self.file.sync_all()?;
}
self.file.seek(SeekFrom::End(0))?;
Ok(entries)
}
fn append(&mut self, entry: &JournalEntry<T>) -> io::Result<()> {
let payload = encode(entry);
writeln!(self.file, "{payload}#{:016x}", checksum(payload.as_bytes()))?;
self.file.sync_all()
}
}
fn invalid(error: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}
fn checksum(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
fn link_text<T: LinkReference>(link: &Link<T>) -> String {
format!("{},{},{}", link.index, link.source, link.target)
}
fn encode<T: LinkReference>(entry: &JournalEntry<T>) -> String {
let body = match entry {
JournalEntry::Begin {
transaction_id,
snapshot,
}
| JournalEntry::Commit {
transaction_id,
snapshot,
} => {
let kind = if matches!(entry, JournalEntry::Begin { .. }) {
"B"
} else {
"C"
};
let snapshot = snapshot.iter().map(link_text).collect::<Vec<_>>().join(";");
format!("{kind}|{transaction_id}|{snapshot}")
}
JournalEntry::Rollback { transaction_id } => format!("R|{transaction_id}"),
JournalEntry::Transition(t) => {
let kind = match t.kind {
TransitionKind::Create => "C",
TransitionKind::Update => "U",
TransitionKind::Delete => "D",
};
format!(
"T|{}|{}|{kind}|{}|{}",
t.transaction_id,
t.sequence,
link_text(&t.before),
link_text(&t.after)
)
}
};
format!("doublets-tx-v1|{body}")
}
fn parse_link<T: LinkReference>(text: &str) -> io::Result<Link<T>> {
let fields: Vec<_> = text.split(',').collect();
let [index, source, target] = fields.as_slice() else {
return Err(invalid("invalid link triple"));
};
let address = |text: &str| {
let value = text.parse::<u128>().map_err(invalid)?;
T::try_from(value)
.map_err(|_| invalid(format!("address {value} exceeds journal address type")))
};
Ok(Link::new(
address(index)?,
address(source)?,
address(target)?,
))
}
fn decode<T: LinkReference>(line: &str) -> io::Result<JournalEntry<T>> {
let (payload, hash) = line
.rsplit_once('#')
.ok_or_else(|| invalid("missing journal checksum"))?;
if checksum(payload.as_bytes()) != u64::from_str_radix(hash, 16).map_err(invalid)? {
return Err(invalid("journal checksum mismatch"));
}
let fields: Vec<_> = payload.split('|').collect();
if fields.first() != Some(&"doublets-tx-v1") {
return Err(invalid("unsupported journal version"));
}
match &fields[1..] {
[kind @ ("B" | "C"), id, snapshot] => {
let transaction_id = id.parse().map_err(invalid)?;
let snapshot = if snapshot.is_empty() {
Vec::new()
} else {
snapshot
.split(';')
.map(parse_link)
.collect::<io::Result<Vec<_>>>()?
};
Ok(if *kind == "B" {
JournalEntry::Begin {
transaction_id,
snapshot,
}
} else {
JournalEntry::Commit {
transaction_id,
snapshot,
}
})
}
["R", id] => Ok(JournalEntry::Rollback {
transaction_id: id.parse().map_err(invalid)?,
}),
["T", id, sequence, kind, before, after] => {
let t = Transition::new(
id.parse().map_err(invalid)?,
sequence.parse().map_err(invalid)?,
parse_link(before)?,
parse_link(after)?,
);
let expected = match t.kind {
TransitionKind::Create => "C",
TransitionKind::Update => "U",
TransitionKind::Delete => "D",
};
if *kind != expected {
return Err(invalid("invalid transition kind"));
}
Ok(JournalEntry::Transition(t))
}
_ => Err(invalid("invalid journal record")),
}
}