use async_trait::async_trait;
use corium_core::{
Datom, EntityId,
encoding::{decode_value, encode_value},
};
use std::{
collections::HashMap,
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex, 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,
#[error("native transaction log store failed: {0}")]
Native(String),
#[error("this transaction log requires asynchronous access")]
AsyncOnly,
}
#[async_trait]
pub trait TransactionLog: Send + Sync {
fn append(&self, record: &TxRecord) -> Result<(), LogError>;
async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
self.append(record)
}
async fn append_batch_async(&self, records: &[TxRecord]) -> Result<(), LogError> {
for record in records {
self.append_async(record).await?;
}
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
async fn tx_range_async(
&self,
start: u64,
end: Option<u64>,
) -> Result<Vec<TxRecord>, LogError> {
self.tx_range(start, end)
}
fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
self.tx_range(0, None)
}
async fn replay_async(&self) -> Result<Vec<TxRecord>, LogError> {
self.tx_range_async(0, None).await
}
}
#[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 merge_versions(mut per_version: Vec<Vec<TxRecord>>) -> Vec<TxRecord> {
let mut cutoff = u64::MAX;
for records in per_version.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);
}
}
per_version.into_iter().flatten().collect()
}
#[async_trait]
pub trait NativeLogStorage: Send + Sync {
async fn put_batch(
&self,
name: &str,
version: u64,
records: &[(u64, Vec<u8>)],
) -> Result<bool, LogError>;
async fn read_record(
&self,
name: &str,
version: u64,
t: u64,
) -> Result<Option<Vec<u8>>, LogError>;
async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
async fn read_legacy_chunk(
&self,
name: &str,
version: u64,
chunk: u64,
) -> Result<Option<Vec<u8>>, LogError>;
async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
async fn delete_all(&self, name: &str) -> Result<(), LogError>;
}
pub struct NativeVersionedLog<S: ?Sized> {
storage: Arc<S>,
name: String,
write_version: u64,
next_t: tokio::sync::Mutex<u64>,
}
impl<S: NativeLogStorage + ?Sized + 'static> NativeVersionedLog<S> {
pub async fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
let records = read_native_merged(storage.as_ref(), name).await?;
let next_t = records.last().map_or(1, |r| r.t + 1);
Ok(Self {
storage,
name: name.to_owned(),
write_version,
next_t: tokio::sync::Mutex::new(next_t),
})
}
}
#[async_trait]
impl<S: NativeLogStorage + ?Sized + 'static> TransactionLog for NativeVersionedLog<S> {
fn append(&self, record: &TxRecord) -> Result<(), LogError> {
let _ = record;
Err(LogError::AsyncOnly)
}
async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
self.append_batch_async(std::slice::from_ref(record)).await
}
async fn append_batch_async(&self, records: &[TxRecord]) -> Result<(), LogError> {
if records.is_empty() {
return Ok(());
}
let mut next_t = self.next_t.lock().await;
for (offset, record) in records.iter().enumerate() {
if record.t != *next_t + offset as u64 {
return Err(LogError::Corrupt);
}
}
let framed = records
.iter()
.map(|record| {
let mut bytes = Vec::new();
append_framed_record(&mut bytes, record)?;
Ok((record.t, bytes))
})
.collect::<Result<Vec<_>, LogError>>()?;
if !self
.storage
.put_batch(&self.name, self.write_version, &framed)
.await?
{
return Err(LogError::Corrupt);
}
*next_t += records.len() as u64;
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
let _ = (start, end);
Err(LogError::AsyncOnly)
}
async fn tx_range_async(
&self,
start: u64,
end: Option<u64>,
) -> Result<Vec<TxRecord>, LogError> {
let _guard = self.next_t.lock().await;
Ok(read_native_merged(self.storage.as_ref(), &self.name)
.await?
.into_iter()
.filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
.collect())
}
}
async fn read_native_merged<S: NativeLogStorage + ?Sized>(
storage: &S,
name: &str,
) -> Result<Vec<TxRecord>, LogError> {
use std::collections::BTreeMap;
let mut per_version: BTreeMap<u64, Vec<TxRecord>> = BTreeMap::new();
let mut chunks = storage.list_legacy_chunks(name).await?;
chunks.sort_unstable();
for (version, chunk) in chunks {
let bytes = storage
.read_legacy_chunk(name, version, chunk)
.await?
.unwrap_or_default();
per_version
.entry(version)
.or_default()
.extend(decode_framed_records(&bytes)?);
}
let mut records = storage.list_records(name).await?;
records.sort_unstable();
for (version, t) in records {
let bytes = storage
.read_record(name, version, t)
.await?
.unwrap_or_default();
per_version
.entry(version)
.or_default()
.extend(decode_framed_records(&bytes)?);
}
let per_version: Vec<Vec<TxRecord>> = per_version
.into_values()
.map(|mut records| {
records.sort_by_key(|record| record.t);
records
})
.collect();
let merged = merge_versions(per_version);
for pair in merged.windows(2) {
if pair[1].t != pair[0].t + 1 {
return Err(LogError::Corrupt);
}
}
Ok(merged)
}
type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
#[derive(Clone, Default)]
pub struct MemLogRegistry {
logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
}
impl MemLogRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn entry(&self, name: &str) -> VersionedRecords {
Arc::clone(
self.logs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(name.to_owned())
.or_default(),
)
}
#[must_use]
pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
let records = self.entry(name);
let next_t = {
let guard = records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
MemVersionedLog::merged(&guard)
.last()
.map_or(1, |r| r.t + 1)
};
MemVersionedLog {
records,
write_version,
next_t: Mutex::new(next_t),
}
}
#[must_use]
pub fn exists(&self, name: &str) -> bool {
self.logs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(name)
.is_some_and(|entry| {
!entry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
})
}
pub fn delete_all(&self, name: &str) {
self.logs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(name);
}
}
pub struct MemVersionedLog {
records: VersionedRecords,
write_version: u64,
next_t: Mutex<u64>,
}
impl MemVersionedLog {
fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
versions.sort_unstable();
versions.dedup();
let per_version = versions
.into_iter()
.map(|version| {
records
.iter()
.filter(|(record_version, _)| *record_version == version)
.map(|(_, record)| record.clone())
.collect::<Vec<_>>()
})
.collect();
merge_versions(per_version)
}
}
impl TransactionLog for MemVersionedLog {
fn append(&self, record: &TxRecord) -> Result<(), LogError> {
let mut next_t = self
.next_t
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if *next_t != record.t {
return Err(LogError::Corrupt);
}
self.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((self.write_version, record.clone()));
*next_t += 1;
Ok(())
}
fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
let records = self
.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(Self::merged(&records)
.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())
&& 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 merged = merge_versions(per_file);
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))
}
pub fn append_framed_record(out: &mut Vec<u8>, record: &TxRecord) -> Result<(), LogError> {
let payload = encode_record(record);
out.extend_from_slice(
&u64::try_from(payload.len())
.map_err(|_| LogError::Corrupt)?
.to_be_bytes(),
);
out.extend_from_slice(&payload);
Ok(())
}
pub fn decode_framed_records(mut bytes: &[u8]) -> Result<Vec<TxRecord>, LogError> {
let mut records = Vec::new();
while !bytes.is_empty() {
if bytes.len() < 8 {
return Err(LogError::Corrupt);
}
let len = usize::try_from(u64::from_be_bytes(
bytes[..8].try_into().map_err(|_| LogError::Corrupt)?,
))
.map_err(|_| LogError::Corrupt)?;
bytes = &bytes[8..];
let payload = bytes.get(..len).ok_or(LogError::Corrupt)?;
records.push(decode_record(payload)?);
bytes = &bytes[len..];
}
Ok(records)
}