use corium_core::{
Datom, EntityId,
encoding::{decode_value, encode_value},
};
use std::{
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use thiserror::Error;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TxRecord {
pub t: u64,
pub tx_instant: i64,
pub datoms: Vec<Datom>,
}
#[derive(Debug, Error)]
pub enum LogError {
#[error("log I/O failed: {0}")]
Io(#[from] io::Error),
#[error("corrupt transaction log")]
Corrupt,
}
pub trait TransactionLog: Send + Sync {
fn append(&self, record: &TxRecord) -> Result<(), LogError>;
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
self.tx_range(0, None)
}
}
#[derive(Clone, Default)]
pub struct MemoryLog(Arc<RwLock<Vec<TxRecord>>>);
impl TransactionLog for MemoryLog {
fn append(&self, record: &TxRecord) -> Result<(), LogError> {
let mut records = self.0.write().expect("poisoned log lock");
if records.last().map_or(1, |r| r.t + 1) != record.t {
return Err(LogError::Corrupt);
}
records.push(record.clone());
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
Ok(self
.0
.read()
.expect("poisoned log lock")
.iter()
.filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
.cloned()
.collect())
}
}
pub struct FileLog {
path: PathBuf,
next_t: RwLock<u64>,
}
impl FileLog {
pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(&path)?;
let (records, durable_len) = read_records(&path)?;
if fs::metadata(&path)?.len() > durable_len {
let file = OpenOptions::new().write(true).open(&path)?;
file.set_len(durable_len)?;
file.sync_all()?;
}
Ok(Self {
path,
next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
})
}
}
impl TransactionLog for FileLog {
fn append(&self, record: &TxRecord) -> Result<(), LogError> {
let mut next_t = self.next_t.write().expect("poisoned log lock");
if *next_t != record.t {
return Err(LogError::Corrupt);
}
let payload = encode_record(record);
let mut file = OpenOptions::new().append(true).open(&self.path)?;
file.write_all(
&u64::try_from(payload.len())
.map_err(|_| LogError::Corrupt)?
.to_be_bytes(),
)?;
file.write_all(&payload)?;
file.sync_all()?;
*next_t += 1;
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
let _guard = self.next_t.read().expect("poisoned log lock");
Ok(read_records(&self.path)?
.0
.into_iter()
.filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
.collect())
}
}
pub struct VersionedLog {
dir: PathBuf,
name: String,
write_path: PathBuf,
next_t: RwLock<u64>,
}
impl VersionedLog {
pub fn open(dir: impl AsRef<Path>, name: &str, write_version: u64) -> Result<Self, LogError> {
let dir = dir.as_ref().to_path_buf();
fs::create_dir_all(&dir)?;
let write_path = version_path(&dir, name, write_version);
OpenOptions::new()
.create(true)
.append(true)
.open(&write_path)?;
let (_, durable_len) = read_records(&write_path)?;
if fs::metadata(&write_path)?.len() > durable_len {
let file = OpenOptions::new().write(true).open(&write_path)?;
file.set_len(durable_len)?;
file.sync_all()?;
}
let records = read_merged(&dir, name)?;
Ok(Self {
dir,
name: name.to_owned(),
write_path,
next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
})
}
pub fn open_read_only(dir: impl AsRef<Path>, name: &str) -> Result<Self, LogError> {
let dir = dir.as_ref().to_path_buf();
Ok(Self {
write_path: PathBuf::new(),
name: name.to_owned(),
next_t: RwLock::new(u64::MAX),
dir,
})
}
#[must_use]
pub fn exists(dir: impl AsRef<Path>, name: &str) -> bool {
!version_files(dir.as_ref(), name).is_empty()
}
pub fn delete_all(dir: impl AsRef<Path>, name: &str) -> Result<(), LogError> {
for (_, path) in version_files(dir.as_ref(), name) {
match fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
}
impl TransactionLog for VersionedLog {
fn append(&self, record: &TxRecord) -> Result<(), LogError> {
let mut next_t = self.next_t.write().expect("poisoned log lock");
if *next_t != record.t {
return Err(LogError::Corrupt);
}
let payload = encode_record(record);
let mut file = OpenOptions::new().append(true).open(&self.write_path)?;
file.write_all(
&u64::try_from(payload.len())
.map_err(|_| LogError::Corrupt)?
.to_be_bytes(),
)?;
file.write_all(&payload)?;
file.sync_all()?;
*next_t += 1;
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
let _guard = self.next_t.read().expect("poisoned log lock");
Ok(read_merged(&self.dir, &self.name)?
.into_iter()
.filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
.collect())
}
}
fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
if version == 0 {
dir.join(format!("{name}.log"))
} else {
dir.join(format!("{name}.v{version}.log"))
}
}
fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
let mut files = Vec::new();
let legacy = version_path(dir, name, 0);
if legacy.is_file() {
files.push((0, legacy));
}
let prefix = format!("{name}.v");
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(text) = file_name.to_str() else {
continue;
};
if let Some(version) = text
.strip_prefix(&prefix)
.and_then(|rest| rest.strip_suffix(".log"))
.and_then(|v| v.parse::<u64>().ok())
{
if version > 0 {
files.push((version, entry.path()));
}
}
}
}
files.sort_by_key(|(version, _)| *version);
files
}
fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
let files = version_files(dir, name);
let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
for (_, path) in &files {
per_file.push(read_records(path)?.0);
}
let mut cutoff = u64::MAX;
for records in per_file.iter_mut().rev() {
let first = records.first().map(|r| r.t);
records.retain(|r| r.t < cutoff);
if let Some(first) = first {
cutoff = cutoff.min(first);
}
}
let merged: Vec<TxRecord> = per_file.into_iter().flatten().collect();
for pair in merged.windows(2) {
if pair[1].t != pair[0].t + 1 {
return Err(LogError::Corrupt);
}
}
Ok(merged)
}
fn encode_record(record: &TxRecord) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&record.t.to_be_bytes());
out.extend_from_slice(&record.tx_instant.to_be_bytes());
out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
for d in &record.datoms {
out.extend_from_slice(&d.e.raw().to_be_bytes());
out.extend_from_slice(&d.a.raw().to_be_bytes());
out.extend_from_slice(&d.tx.raw().to_be_bytes());
out.push(u8::from(d.added));
let v = encode_value(&d.v);
out.extend_from_slice(&(v.len() as u64).to_be_bytes());
out.extend_from_slice(&v);
}
out
}
fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
*bytes = &bytes[n..];
Ok(value)
}
fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
Ok(u64::from_be_bytes(
take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
))
}
let t = u64_be(&mut bytes)?;
let tx_instant = i64::from_be_bytes(
take(&mut bytes, 8)?
.try_into()
.map_err(|_| LogError::Corrupt)?,
);
let count = u64_be(&mut bytes)?;
let mut datoms = Vec::new();
for _ in 0..count {
let e = EntityId::from_raw(u64_be(&mut bytes)?);
let a = EntityId::from_raw(u64_be(&mut bytes)?);
let tx = EntityId::from_raw(u64_be(&mut bytes)?);
let added = take(&mut bytes, 1)?[0] != 0;
let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
let raw = take(&mut bytes, len)?;
let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
if used != len {
return Err(LogError::Corrupt);
}
datoms.push(Datom { e, a, v, tx, added });
}
if !bytes.is_empty() {
return Err(LogError::Corrupt);
}
Ok(TxRecord {
t,
tx_instant,
datoms,
})
}
fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
let mut file = File::open(path)?;
let mut records = Vec::new();
let mut durable_len = 0_u64;
loop {
let mut len = [0; 8];
match file.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e.into()),
}
let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
let mut payload = vec![0; len];
match file.read_exact(&mut payload) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e.into()),
}
records.push(decode_record(&payload)?);
durable_len += 8 + len as u64;
}
Ok((records, durable_len))
}