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 #[error("native transaction log store failed: {0}")]
38 Native(String),
39}
40
41pub trait TransactionLog: Send + Sync {
43 fn append(&self, record: &TxRecord) -> Result<(), LogError>;
48 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
53 fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
58 self.tx_range(0, None)
59 }
60}
61
62#[derive(Clone, Default)]
64pub struct MemoryLog(Arc<RwLock<Vec<TxRecord>>>);
65impl TransactionLog for MemoryLog {
66 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
67 let mut records = self.0.write().expect("poisoned log lock");
68 if records.last().map_or(1, |r| r.t + 1) != record.t {
69 return Err(LogError::Corrupt);
70 }
71 records.push(record.clone());
72 Ok(())
73 }
74 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
75 Ok(self
76 .0
77 .read()
78 .expect("poisoned log lock")
79 .iter()
80 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
81 .cloned()
82 .collect())
83 }
84}
85
86pub struct FileLog {
92 path: PathBuf,
93 next_t: RwLock<u64>,
94}
95impl FileLog {
96 pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
102 let path = path.as_ref().to_path_buf();
103 if let Some(parent) = path.parent() {
104 fs::create_dir_all(parent)?;
105 }
106 OpenOptions::new().create(true).append(true).open(&path)?;
107 let (records, durable_len) = read_records(&path)?;
108 if fs::metadata(&path)?.len() > durable_len {
109 let file = OpenOptions::new().write(true).open(&path)?;
110 file.set_len(durable_len)?;
111 file.sync_all()?;
112 }
113 Ok(Self {
114 path,
115 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
116 })
117 }
118}
119impl TransactionLog for FileLog {
120 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
121 let mut next_t = self.next_t.write().expect("poisoned log lock");
122 if *next_t != record.t {
123 return Err(LogError::Corrupt);
124 }
125 let payload = encode_record(record);
126 let mut file = OpenOptions::new().append(true).open(&self.path)?;
127 file.write_all(
128 &u64::try_from(payload.len())
129 .map_err(|_| LogError::Corrupt)?
130 .to_be_bytes(),
131 )?;
132 file.write_all(&payload)?;
133 file.sync_all()?;
134 *next_t += 1;
135 Ok(())
136 }
137 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
138 let _guard = self.next_t.read().expect("poisoned log lock");
139 Ok(read_records(&self.path)?
140 .0
141 .into_iter()
142 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
143 .collect())
144 }
145}
146
147pub struct VersionedLog {
159 dir: PathBuf,
160 name: String,
161 write_path: PathBuf,
162 next_t: RwLock<u64>,
163}
164
165impl VersionedLog {
166 pub fn open(dir: impl AsRef<Path>, name: &str, write_version: u64) -> Result<Self, LogError> {
174 let dir = dir.as_ref().to_path_buf();
175 fs::create_dir_all(&dir)?;
176 let write_path = version_path(&dir, name, write_version);
177 OpenOptions::new()
178 .create(true)
179 .append(true)
180 .open(&write_path)?;
181 let (_, durable_len) = read_records(&write_path)?;
182 if fs::metadata(&write_path)?.len() > durable_len {
183 let file = OpenOptions::new().write(true).open(&write_path)?;
184 file.set_len(durable_len)?;
185 file.sync_all()?;
186 }
187 let records = read_merged(&dir, name)?;
188 Ok(Self {
189 dir,
190 name: name.to_owned(),
191 write_path,
192 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
193 })
194 }
195
196 pub fn open_read_only(dir: impl AsRef<Path>, name: &str) -> Result<Self, LogError> {
202 let dir = dir.as_ref().to_path_buf();
203 Ok(Self {
204 write_path: PathBuf::new(),
205 name: name.to_owned(),
206 next_t: RwLock::new(u64::MAX),
207 dir,
208 })
209 }
210
211 #[must_use]
213 pub fn exists(dir: impl AsRef<Path>, name: &str) -> bool {
214 !version_files(dir.as_ref(), name).is_empty()
215 }
216
217 pub fn delete_all(dir: impl AsRef<Path>, name: &str) -> Result<(), LogError> {
222 for (_, path) in version_files(dir.as_ref(), name) {
223 match fs::remove_file(&path) {
224 Ok(()) => {}
225 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
226 Err(error) => return Err(error.into()),
227 }
228 }
229 Ok(())
230 }
231}
232
233impl TransactionLog for VersionedLog {
234 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
235 let mut next_t = self.next_t.write().expect("poisoned log lock");
236 if *next_t != record.t {
237 return Err(LogError::Corrupt);
238 }
239 let payload = encode_record(record);
240 let mut file = OpenOptions::new().append(true).open(&self.write_path)?;
241 file.write_all(
242 &u64::try_from(payload.len())
243 .map_err(|_| LogError::Corrupt)?
244 .to_be_bytes(),
245 )?;
246 file.write_all(&payload)?;
247 file.sync_all()?;
248 *next_t += 1;
249 Ok(())
250 }
251
252 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
253 let _guard = self.next_t.read().expect("poisoned log lock");
254 Ok(read_merged(&self.dir, &self.name)?
255 .into_iter()
256 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
257 .collect())
258 }
259}
260
261fn merge_versions(mut per_version: Vec<Vec<TxRecord>>) -> Vec<TxRecord> {
266 let mut cutoff = u64::MAX;
267 for records in per_version.iter_mut().rev() {
268 let first = records.first().map(|r| r.t);
269 records.retain(|r| r.t < cutoff);
270 if let Some(first) = first {
271 cutoff = cutoff.min(first);
272 }
273 }
274 per_version.into_iter().flatten().collect()
275}
276
277pub(crate) const LOG_CHUNK_MAX_BYTES: usize = 256 * 1024;
282
283pub trait NativeLogStorage: Send + Sync {
292 fn read_chunk(&self, name: &str, version: u64, chunk: u64)
298 -> Result<Option<Vec<u8>>, LogError>;
299 fn cas_chunk(
306 &self,
307 name: &str,
308 version: u64,
309 chunk: u64,
310 expected: Option<&[u8]>,
311 new: &[u8],
312 ) -> Result<(), LogError>;
313 fn list_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
319 fn delete_all(&self, name: &str) -> Result<(), LogError>;
324}
325
326struct WriteState {
340 next_t: u64,
342 chunk: u64,
344 bytes: Vec<u8>,
347 exists: bool,
351}
352
353pub struct NativeVersionedLog<S: ?Sized> {
355 storage: Arc<S>,
356 name: String,
357 write_version: u64,
358 write: Mutex<WriteState>,
359}
360
361impl<S: NativeLogStorage + ?Sized + 'static> NativeVersionedLog<S> {
362 pub fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
367 let records = read_native_merged(storage.as_ref(), name)?;
370 let next_t = records.last().map_or(1, |r| r.t + 1);
371 let chunk = storage
375 .list_chunks(name)?
376 .into_iter()
377 .filter_map(|(version, chunk)| (version == write_version).then_some(chunk))
378 .max()
379 .unwrap_or(0);
380 let current = storage.read_chunk(name, write_version, chunk)?;
381 let exists = current.is_some();
382 let bytes = current.unwrap_or_default();
383 Ok(Self {
384 storage,
385 name: name.to_owned(),
386 write_version,
387 write: Mutex::new(WriteState {
388 next_t,
389 chunk,
390 bytes,
391 exists,
392 }),
393 })
394 }
395}
396
397impl<S: NativeLogStorage + ?Sized + 'static> TransactionLog for NativeVersionedLog<S> {
398 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
399 let mut write = self
400 .write
401 .lock()
402 .unwrap_or_else(std::sync::PoisonError::into_inner);
403 if write.next_t != record.t {
404 return Err(LogError::Corrupt);
405 }
406 if write.exists && write.bytes.len() >= LOG_CHUNK_MAX_BYTES {
410 write.chunk += 1;
411 write.bytes.clear();
412 write.exists = false;
413 }
414 let chunk = write.chunk;
415 let exists = write.exists;
416 let old_len = write.bytes.len();
417 append_framed_record(&mut write.bytes, record)?;
418 let expected = if exists {
422 Some(&write.bytes[..old_len])
423 } else {
424 None
425 };
426 match self.storage.cas_chunk(
427 &self.name,
428 self.write_version,
429 chunk,
430 expected,
431 &write.bytes,
432 ) {
433 Ok(()) => {
434 write.exists = true;
435 write.next_t += 1;
436 Ok(())
437 }
438 Err(error) => {
439 write.bytes.truncate(old_len);
441 Err(error)
442 }
443 }
444 }
445
446 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
447 let _guard = self
450 .write
451 .lock()
452 .unwrap_or_else(std::sync::PoisonError::into_inner);
453 Ok(read_native_merged(self.storage.as_ref(), &self.name)?
454 .into_iter()
455 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
456 .collect())
457 }
458}
459
460fn read_native_merged<S: NativeLogStorage + ?Sized>(
461 storage: &S,
462 name: &str,
463) -> Result<Vec<TxRecord>, LogError> {
464 let mut chunks = storage.list_chunks(name)?;
467 chunks.sort_unstable();
468 let mut per_version: Vec<Vec<TxRecord>> = Vec::new();
469 let mut current_version: Option<u64> = None;
470 for (version, chunk) in chunks {
471 if current_version != Some(version) {
472 per_version.push(Vec::new());
473 current_version = Some(version);
474 }
475 let bytes = storage
476 .read_chunk(name, version, chunk)?
477 .unwrap_or_default();
478 per_version
479 .last_mut()
480 .expect("a version group was pushed")
481 .extend(decode_framed_records(&bytes)?);
482 }
483 let merged = merge_versions(per_version);
484 for pair in merged.windows(2) {
485 if pair[1].t != pair[0].t + 1 {
486 return Err(LogError::Corrupt);
487 }
488 }
489 Ok(merged)
490}
491
492type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
495
496#[derive(Clone, Default)]
502pub struct MemLogRegistry {
503 logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
504}
505
506impl MemLogRegistry {
507 #[must_use]
509 pub fn new() -> Self {
510 Self::default()
511 }
512
513 fn entry(&self, name: &str) -> VersionedRecords {
514 Arc::clone(
515 self.logs
516 .lock()
517 .unwrap_or_else(std::sync::PoisonError::into_inner)
518 .entry(name.to_owned())
519 .or_default(),
520 )
521 }
522
523 #[must_use]
526 pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
527 let records = self.entry(name);
528 let next_t = {
529 let guard = records
530 .lock()
531 .unwrap_or_else(std::sync::PoisonError::into_inner);
532 MemVersionedLog::merged(&guard)
533 .last()
534 .map_or(1, |r| r.t + 1)
535 };
536 MemVersionedLog {
537 records,
538 write_version,
539 next_t: Mutex::new(next_t),
540 }
541 }
542
543 #[must_use]
545 pub fn exists(&self, name: &str) -> bool {
546 self.logs
547 .lock()
548 .unwrap_or_else(std::sync::PoisonError::into_inner)
549 .get(name)
550 .is_some_and(|entry| {
551 !entry
552 .lock()
553 .unwrap_or_else(std::sync::PoisonError::into_inner)
554 .is_empty()
555 })
556 }
557
558 pub fn delete_all(&self, name: &str) {
560 self.logs
561 .lock()
562 .unwrap_or_else(std::sync::PoisonError::into_inner)
563 .remove(name);
564 }
565}
566
567pub struct MemVersionedLog {
571 records: VersionedRecords,
572 write_version: u64,
573 next_t: Mutex<u64>,
577}
578
579impl MemVersionedLog {
580 fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
581 let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
582 versions.sort_unstable();
583 versions.dedup();
584 let per_version = versions
585 .into_iter()
586 .map(|version| {
587 records
588 .iter()
589 .filter(|(record_version, _)| *record_version == version)
590 .map(|(_, record)| record.clone())
591 .collect::<Vec<_>>()
592 })
593 .collect();
594 merge_versions(per_version)
595 }
596}
597
598impl TransactionLog for MemVersionedLog {
599 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
600 let mut next_t = self
601 .next_t
602 .lock()
603 .unwrap_or_else(std::sync::PoisonError::into_inner);
604 if *next_t != record.t {
605 return Err(LogError::Corrupt);
606 }
607 self.records
608 .lock()
609 .unwrap_or_else(std::sync::PoisonError::into_inner)
610 .push((self.write_version, record.clone()));
611 *next_t += 1;
612 Ok(())
613 }
614
615 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
616 let records = self
617 .records
618 .lock()
619 .unwrap_or_else(std::sync::PoisonError::into_inner);
620 Ok(Self::merged(&records)
621 .into_iter()
622 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
623 .collect())
624 }
625}
626
627fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
628 if version == 0 {
629 dir.join(format!("{name}.log"))
630 } else {
631 dir.join(format!("{name}.v{version}.log"))
632 }
633}
634
635fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
637 let mut files = Vec::new();
638 let legacy = version_path(dir, name, 0);
639 if legacy.is_file() {
640 files.push((0, legacy));
641 }
642 let prefix = format!("{name}.v");
643 if let Ok(entries) = fs::read_dir(dir) {
644 for entry in entries.flatten() {
645 let file_name = entry.file_name();
646 let Some(text) = file_name.to_str() else {
647 continue;
648 };
649 if let Some(version) = text
650 .strip_prefix(&prefix)
651 .and_then(|rest| rest.strip_suffix(".log"))
652 .and_then(|v| v.parse::<u64>().ok())
653 && version > 0
654 {
655 files.push((version, entry.path()));
656 }
657 }
658 }
659 files.sort_by_key(|(version, _)| *version);
660 files
661}
662
663fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
666 let files = version_files(dir, name);
667 let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
668 for (_, path) in &files {
669 per_file.push(read_records(path)?.0);
670 }
671 let merged = merge_versions(per_file);
676 for pair in merged.windows(2) {
677 if pair[1].t != pair[0].t + 1 {
678 return Err(LogError::Corrupt);
679 }
680 }
681 Ok(merged)
682}
683
684fn encode_record(record: &TxRecord) -> Vec<u8> {
685 let mut out = Vec::new();
686 out.extend_from_slice(&record.t.to_be_bytes());
687 out.extend_from_slice(&record.tx_instant.to_be_bytes());
688 out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
689 for d in &record.datoms {
690 out.extend_from_slice(&d.e.raw().to_be_bytes());
691 out.extend_from_slice(&d.a.raw().to_be_bytes());
692 out.extend_from_slice(&d.tx.raw().to_be_bytes());
693 out.push(u8::from(d.added));
694 let v = encode_value(&d.v);
695 out.extend_from_slice(&(v.len() as u64).to_be_bytes());
696 out.extend_from_slice(&v);
697 }
698 out
699}
700fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
701 fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
702 let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
703 *bytes = &bytes[n..];
704 Ok(value)
705 }
706 fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
707 Ok(u64::from_be_bytes(
708 take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
709 ))
710 }
711 let t = u64_be(&mut bytes)?;
712 let tx_instant = i64::from_be_bytes(
713 take(&mut bytes, 8)?
714 .try_into()
715 .map_err(|_| LogError::Corrupt)?,
716 );
717 let count = u64_be(&mut bytes)?;
718 let mut datoms = Vec::new();
719 for _ in 0..count {
720 let e = EntityId::from_raw(u64_be(&mut bytes)?);
721 let a = EntityId::from_raw(u64_be(&mut bytes)?);
722 let tx = EntityId::from_raw(u64_be(&mut bytes)?);
723 let added = take(&mut bytes, 1)?[0] != 0;
724 let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
725 let raw = take(&mut bytes, len)?;
726 let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
727 if used != len {
728 return Err(LogError::Corrupt);
729 }
730 datoms.push(Datom { e, a, v, tx, added });
731 }
732 if !bytes.is_empty() {
733 return Err(LogError::Corrupt);
734 }
735 Ok(TxRecord {
736 t,
737 tx_instant,
738 datoms,
739 })
740}
741fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
747 let mut file = File::open(path)?;
748 let mut records = Vec::new();
749 let mut durable_len = 0_u64;
750 loop {
751 let mut len = [0; 8];
752 match file.read_exact(&mut len) {
753 Ok(()) => {}
754 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
755 Err(e) => return Err(e.into()),
756 }
757 let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
758 let mut payload = vec![0; len];
759 match file.read_exact(&mut payload) {
760 Ok(()) => {}
761 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
762 Err(e) => return Err(e.into()),
763 }
764 records.push(decode_record(&payload)?);
765 durable_len += 8 + len as u64;
766 }
767 Ok((records, durable_len))
768}
769
770pub fn append_framed_record(out: &mut Vec<u8>, record: &TxRecord) -> Result<(), LogError> {
775 let payload = encode_record(record);
776 out.extend_from_slice(
777 &u64::try_from(payload.len())
778 .map_err(|_| LogError::Corrupt)?
779 .to_be_bytes(),
780 );
781 out.extend_from_slice(&payload);
782 Ok(())
783}
784
785pub fn decode_framed_records(mut bytes: &[u8]) -> Result<Vec<TxRecord>, LogError> {
794 let mut records = Vec::new();
795 while !bytes.is_empty() {
796 if bytes.len() < 8 {
797 return Err(LogError::Corrupt);
798 }
799 let len = usize::try_from(u64::from_be_bytes(
800 bytes[..8].try_into().map_err(|_| LogError::Corrupt)?,
801 ))
802 .map_err(|_| LogError::Corrupt)?;
803 bytes = &bytes[8..];
804 let payload = bytes.get(..len).ok_or(LogError::Corrupt)?;
805 records.push(decode_record(payload)?);
806 bytes = &bytes[len..];
807 }
808 Ok(records)
809}