1use corium_core::{
4 Datom, EntityId,
5 encoding::{decode_value, encode_value},
6};
7use std::{
8 collections::HashMap,
9 fs::{self, File, OpenOptions},
10 io::{self, Read, Write},
11 path::{Path, PathBuf},
12 sync::{Arc, Mutex, RwLock},
13};
14use thiserror::Error;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct TxRecord {
19 pub t: u64,
21 pub tx_instant: i64,
23 pub datoms: Vec<Datom>,
25}
26
27#[derive(Debug, Error)]
29pub enum LogError {
30 #[error("log I/O failed: {0}")]
32 Io(#[from] io::Error),
33 #[error("corrupt transaction log")]
35 Corrupt,
36}
37
38pub trait TransactionLog: Send + Sync {
40 fn append(&self, record: &TxRecord) -> Result<(), LogError>;
45 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
50 fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
55 self.tx_range(0, None)
56 }
57}
58
59#[derive(Clone, Default)]
61pub struct MemoryLog(Arc<RwLock<Vec<TxRecord>>>);
62impl TransactionLog for MemoryLog {
63 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
64 let mut records = self.0.write().expect("poisoned log lock");
65 if records.last().map_or(1, |r| r.t + 1) != record.t {
66 return Err(LogError::Corrupt);
67 }
68 records.push(record.clone());
69 Ok(())
70 }
71 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
72 Ok(self
73 .0
74 .read()
75 .expect("poisoned log lock")
76 .iter()
77 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
78 .cloned()
79 .collect())
80 }
81}
82
83pub struct FileLog {
89 path: PathBuf,
90 next_t: RwLock<u64>,
91}
92impl FileLog {
93 pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
99 let path = path.as_ref().to_path_buf();
100 if let Some(parent) = path.parent() {
101 fs::create_dir_all(parent)?;
102 }
103 OpenOptions::new().create(true).append(true).open(&path)?;
104 let (records, durable_len) = read_records(&path)?;
105 if fs::metadata(&path)?.len() > durable_len {
106 let file = OpenOptions::new().write(true).open(&path)?;
107 file.set_len(durable_len)?;
108 file.sync_all()?;
109 }
110 Ok(Self {
111 path,
112 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
113 })
114 }
115}
116impl TransactionLog for FileLog {
117 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
118 let mut next_t = self.next_t.write().expect("poisoned log lock");
119 if *next_t != record.t {
120 return Err(LogError::Corrupt);
121 }
122 let payload = encode_record(record);
123 let mut file = OpenOptions::new().append(true).open(&self.path)?;
124 file.write_all(
125 &u64::try_from(payload.len())
126 .map_err(|_| LogError::Corrupt)?
127 .to_be_bytes(),
128 )?;
129 file.write_all(&payload)?;
130 file.sync_all()?;
131 *next_t += 1;
132 Ok(())
133 }
134 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
135 let _guard = self.next_t.read().expect("poisoned log lock");
136 Ok(read_records(&self.path)?
137 .0
138 .into_iter()
139 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
140 .collect())
141 }
142}
143
144pub struct VersionedLog {
156 dir: PathBuf,
157 name: String,
158 write_path: PathBuf,
159 next_t: RwLock<u64>,
160}
161
162impl VersionedLog {
163 pub fn open(dir: impl AsRef<Path>, name: &str, write_version: u64) -> Result<Self, LogError> {
171 let dir = dir.as_ref().to_path_buf();
172 fs::create_dir_all(&dir)?;
173 let write_path = version_path(&dir, name, write_version);
174 OpenOptions::new()
175 .create(true)
176 .append(true)
177 .open(&write_path)?;
178 let (_, durable_len) = read_records(&write_path)?;
179 if fs::metadata(&write_path)?.len() > durable_len {
180 let file = OpenOptions::new().write(true).open(&write_path)?;
181 file.set_len(durable_len)?;
182 file.sync_all()?;
183 }
184 let records = read_merged(&dir, name)?;
185 Ok(Self {
186 dir,
187 name: name.to_owned(),
188 write_path,
189 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
190 })
191 }
192
193 pub fn open_read_only(dir: impl AsRef<Path>, name: &str) -> Result<Self, LogError> {
199 let dir = dir.as_ref().to_path_buf();
200 Ok(Self {
201 write_path: PathBuf::new(),
202 name: name.to_owned(),
203 next_t: RwLock::new(u64::MAX),
204 dir,
205 })
206 }
207
208 #[must_use]
210 pub fn exists(dir: impl AsRef<Path>, name: &str) -> bool {
211 !version_files(dir.as_ref(), name).is_empty()
212 }
213
214 pub fn delete_all(dir: impl AsRef<Path>, name: &str) -> Result<(), LogError> {
219 for (_, path) in version_files(dir.as_ref(), name) {
220 match fs::remove_file(&path) {
221 Ok(()) => {}
222 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
223 Err(error) => return Err(error.into()),
224 }
225 }
226 Ok(())
227 }
228}
229
230impl TransactionLog for VersionedLog {
231 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
232 let mut next_t = self.next_t.write().expect("poisoned log lock");
233 if *next_t != record.t {
234 return Err(LogError::Corrupt);
235 }
236 let payload = encode_record(record);
237 let mut file = OpenOptions::new().append(true).open(&self.write_path)?;
238 file.write_all(
239 &u64::try_from(payload.len())
240 .map_err(|_| LogError::Corrupt)?
241 .to_be_bytes(),
242 )?;
243 file.write_all(&payload)?;
244 file.sync_all()?;
245 *next_t += 1;
246 Ok(())
247 }
248
249 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
250 let _guard = self.next_t.read().expect("poisoned log lock");
251 Ok(read_merged(&self.dir, &self.name)?
252 .into_iter()
253 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
254 .collect())
255 }
256}
257
258fn merge_versions(mut per_version: Vec<Vec<TxRecord>>) -> Vec<TxRecord> {
263 let mut cutoff = u64::MAX;
264 for records in per_version.iter_mut().rev() {
265 let first = records.first().map(|r| r.t);
266 records.retain(|r| r.t < cutoff);
267 if let Some(first) = first {
268 cutoff = cutoff.min(first);
269 }
270 }
271 per_version.into_iter().flatten().collect()
272}
273
274type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
277
278#[derive(Clone, Default)]
284pub struct MemLogRegistry {
285 logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
286}
287
288impl MemLogRegistry {
289 #[must_use]
291 pub fn new() -> Self {
292 Self::default()
293 }
294
295 fn entry(&self, name: &str) -> VersionedRecords {
296 Arc::clone(
297 self.logs
298 .lock()
299 .unwrap_or_else(std::sync::PoisonError::into_inner)
300 .entry(name.to_owned())
301 .or_default(),
302 )
303 }
304
305 #[must_use]
308 pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
309 let records = self.entry(name);
310 let next_t = {
311 let guard = records
312 .lock()
313 .unwrap_or_else(std::sync::PoisonError::into_inner);
314 MemVersionedLog::merged(&guard)
315 .last()
316 .map_or(1, |r| r.t + 1)
317 };
318 MemVersionedLog {
319 records,
320 write_version,
321 next_t: Mutex::new(next_t),
322 }
323 }
324
325 #[must_use]
327 pub fn exists(&self, name: &str) -> bool {
328 self.logs
329 .lock()
330 .unwrap_or_else(std::sync::PoisonError::into_inner)
331 .get(name)
332 .is_some_and(|entry| {
333 !entry
334 .lock()
335 .unwrap_or_else(std::sync::PoisonError::into_inner)
336 .is_empty()
337 })
338 }
339
340 pub fn delete_all(&self, name: &str) {
342 self.logs
343 .lock()
344 .unwrap_or_else(std::sync::PoisonError::into_inner)
345 .remove(name);
346 }
347}
348
349pub struct MemVersionedLog {
353 records: VersionedRecords,
354 write_version: u64,
355 next_t: Mutex<u64>,
359}
360
361impl MemVersionedLog {
362 fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
363 let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
364 versions.sort_unstable();
365 versions.dedup();
366 let per_version = versions
367 .into_iter()
368 .map(|version| {
369 records
370 .iter()
371 .filter(|(record_version, _)| *record_version == version)
372 .map(|(_, record)| record.clone())
373 .collect::<Vec<_>>()
374 })
375 .collect();
376 merge_versions(per_version)
377 }
378}
379
380impl TransactionLog for MemVersionedLog {
381 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
382 let mut next_t = self
383 .next_t
384 .lock()
385 .unwrap_or_else(std::sync::PoisonError::into_inner);
386 if *next_t != record.t {
387 return Err(LogError::Corrupt);
388 }
389 self.records
390 .lock()
391 .unwrap_or_else(std::sync::PoisonError::into_inner)
392 .push((self.write_version, record.clone()));
393 *next_t += 1;
394 Ok(())
395 }
396
397 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
398 let records = self
399 .records
400 .lock()
401 .unwrap_or_else(std::sync::PoisonError::into_inner);
402 Ok(Self::merged(&records)
403 .into_iter()
404 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
405 .collect())
406 }
407}
408
409fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
410 if version == 0 {
411 dir.join(format!("{name}.log"))
412 } else {
413 dir.join(format!("{name}.v{version}.log"))
414 }
415}
416
417fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
419 let mut files = Vec::new();
420 let legacy = version_path(dir, name, 0);
421 if legacy.is_file() {
422 files.push((0, legacy));
423 }
424 let prefix = format!("{name}.v");
425 if let Ok(entries) = fs::read_dir(dir) {
426 for entry in entries.flatten() {
427 let file_name = entry.file_name();
428 let Some(text) = file_name.to_str() else {
429 continue;
430 };
431 if let Some(version) = text
432 .strip_prefix(&prefix)
433 .and_then(|rest| rest.strip_suffix(".log"))
434 .and_then(|v| v.parse::<u64>().ok())
435 && version > 0
436 {
437 files.push((version, entry.path()));
438 }
439 }
440 }
441 files.sort_by_key(|(version, _)| *version);
442 files
443}
444
445fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
448 let files = version_files(dir, name);
449 let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
450 for (_, path) in &files {
451 per_file.push(read_records(path)?.0);
452 }
453 let merged = merge_versions(per_file);
458 for pair in merged.windows(2) {
459 if pair[1].t != pair[0].t + 1 {
460 return Err(LogError::Corrupt);
461 }
462 }
463 Ok(merged)
464}
465
466fn encode_record(record: &TxRecord) -> Vec<u8> {
467 let mut out = Vec::new();
468 out.extend_from_slice(&record.t.to_be_bytes());
469 out.extend_from_slice(&record.tx_instant.to_be_bytes());
470 out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
471 for d in &record.datoms {
472 out.extend_from_slice(&d.e.raw().to_be_bytes());
473 out.extend_from_slice(&d.a.raw().to_be_bytes());
474 out.extend_from_slice(&d.tx.raw().to_be_bytes());
475 out.push(u8::from(d.added));
476 let v = encode_value(&d.v);
477 out.extend_from_slice(&(v.len() as u64).to_be_bytes());
478 out.extend_from_slice(&v);
479 }
480 out
481}
482fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
483 fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
484 let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
485 *bytes = &bytes[n..];
486 Ok(value)
487 }
488 fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
489 Ok(u64::from_be_bytes(
490 take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
491 ))
492 }
493 let t = u64_be(&mut bytes)?;
494 let tx_instant = i64::from_be_bytes(
495 take(&mut bytes, 8)?
496 .try_into()
497 .map_err(|_| LogError::Corrupt)?,
498 );
499 let count = u64_be(&mut bytes)?;
500 let mut datoms = Vec::new();
501 for _ in 0..count {
502 let e = EntityId::from_raw(u64_be(&mut bytes)?);
503 let a = EntityId::from_raw(u64_be(&mut bytes)?);
504 let tx = EntityId::from_raw(u64_be(&mut bytes)?);
505 let added = take(&mut bytes, 1)?[0] != 0;
506 let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
507 let raw = take(&mut bytes, len)?;
508 let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
509 if used != len {
510 return Err(LogError::Corrupt);
511 }
512 datoms.push(Datom { e, a, v, tx, added });
513 }
514 if !bytes.is_empty() {
515 return Err(LogError::Corrupt);
516 }
517 Ok(TxRecord {
518 t,
519 tx_instant,
520 datoms,
521 })
522}
523fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
529 let mut file = File::open(path)?;
530 let mut records = Vec::new();
531 let mut durable_len = 0_u64;
532 loop {
533 let mut len = [0; 8];
534 match file.read_exact(&mut len) {
535 Ok(()) => {}
536 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
537 Err(e) => return Err(e.into()),
538 }
539 let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
540 let mut payload = vec![0; len];
541 match file.read_exact(&mut payload) {
542 Ok(()) => {}
543 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
544 Err(e) => return Err(e.into()),
545 }
546 records.push(decode_record(&payload)?);
547 durable_len += 8 + len as u64;
548 }
549 Ok((records, durable_len))
550}