1use async_trait::async_trait;
4use corium_core::{
5 Datom, EntityId,
6 encoding::{decode_value, encode_value},
7};
8use std::{
9 collections::HashMap,
10 fs::{self, File, OpenOptions},
11 io::{self, Read, Write},
12 path::{Path, PathBuf},
13 sync::{Arc, Mutex, RwLock},
14};
15use thiserror::Error;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct TxRecord {
20 pub t: u64,
22 pub tx_instant: i64,
24 pub datoms: Vec<Datom>,
26}
27
28#[derive(Debug, Error)]
30pub enum LogError {
31 #[error("log I/O failed: {0}")]
33 Io(#[from] io::Error),
34 #[error("corrupt transaction log")]
36 Corrupt,
37 #[error("native transaction log store failed: {0}")]
39 Native(String),
40 #[error("this transaction log requires asynchronous access")]
42 AsyncOnly,
43}
44
45#[async_trait]
47pub trait TransactionLog: Send + Sync {
48 fn append(&self, record: &TxRecord) -> Result<(), LogError>;
53 async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
60 self.append(record)
61 }
62 async fn append_batch_async(&self, records: &[TxRecord]) -> Result<(), LogError> {
72 for record in records {
73 self.append_async(record).await?;
74 }
75 Ok(())
76 }
77 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError>;
82 async fn tx_range_async(
87 &self,
88 start: u64,
89 end: Option<u64>,
90 ) -> Result<Vec<TxRecord>, LogError> {
91 self.tx_range(start, end)
92 }
93 fn replay(&self) -> Result<Vec<TxRecord>, LogError> {
98 self.tx_range(0, None)
99 }
100 async fn replay_async(&self) -> Result<Vec<TxRecord>, LogError> {
105 self.tx_range_async(0, None).await
106 }
107}
108
109#[derive(Clone, Default)]
111pub struct MemoryLog(Arc<RwLock<Vec<TxRecord>>>);
112impl TransactionLog for MemoryLog {
113 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
114 let mut records = self.0.write().expect("poisoned log lock");
115 if records.last().map_or(1, |r| r.t + 1) != record.t {
116 return Err(LogError::Corrupt);
117 }
118 records.push(record.clone());
119 Ok(())
120 }
121 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
122 Ok(self
123 .0
124 .read()
125 .expect("poisoned log lock")
126 .iter()
127 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
128 .cloned()
129 .collect())
130 }
131}
132
133pub struct FileLog {
139 path: PathBuf,
140 next_t: RwLock<u64>,
141}
142impl FileLog {
143 pub fn open(path: impl AsRef<Path>) -> Result<Self, LogError> {
149 let path = path.as_ref().to_path_buf();
150 if let Some(parent) = path.parent() {
151 fs::create_dir_all(parent)?;
152 }
153 OpenOptions::new().create(true).append(true).open(&path)?;
154 let (records, durable_len) = read_records(&path)?;
155 if fs::metadata(&path)?.len() > durable_len {
156 let file = OpenOptions::new().write(true).open(&path)?;
157 file.set_len(durable_len)?;
158 file.sync_all()?;
159 }
160 Ok(Self {
161 path,
162 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
163 })
164 }
165}
166impl TransactionLog for FileLog {
167 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
168 let mut next_t = self.next_t.write().expect("poisoned log lock");
169 if *next_t != record.t {
170 return Err(LogError::Corrupt);
171 }
172 let payload = encode_record(record);
173 let mut file = OpenOptions::new().append(true).open(&self.path)?;
174 file.write_all(
175 &u64::try_from(payload.len())
176 .map_err(|_| LogError::Corrupt)?
177 .to_be_bytes(),
178 )?;
179 file.write_all(&payload)?;
180 file.sync_all()?;
181 *next_t += 1;
182 Ok(())
183 }
184 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
185 let _guard = self.next_t.read().expect("poisoned log lock");
186 Ok(read_records(&self.path)?
187 .0
188 .into_iter()
189 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
190 .collect())
191 }
192}
193
194pub struct VersionedLog {
206 dir: PathBuf,
207 name: String,
208 write_path: PathBuf,
209 next_t: RwLock<u64>,
210}
211
212impl VersionedLog {
213 pub fn open(dir: impl AsRef<Path>, name: &str, write_version: u64) -> Result<Self, LogError> {
221 let dir = dir.as_ref().to_path_buf();
222 fs::create_dir_all(&dir)?;
223 let write_path = version_path(&dir, name, write_version);
224 OpenOptions::new()
225 .create(true)
226 .append(true)
227 .open(&write_path)?;
228 let (_, durable_len) = read_records(&write_path)?;
229 if fs::metadata(&write_path)?.len() > durable_len {
230 let file = OpenOptions::new().write(true).open(&write_path)?;
231 file.set_len(durable_len)?;
232 file.sync_all()?;
233 }
234 let records = read_merged(&dir, name)?;
235 Ok(Self {
236 dir,
237 name: name.to_owned(),
238 write_path,
239 next_t: RwLock::new(records.last().map_or(1, |r| r.t + 1)),
240 })
241 }
242
243 pub fn open_read_only(dir: impl AsRef<Path>, name: &str) -> Result<Self, LogError> {
249 let dir = dir.as_ref().to_path_buf();
250 Ok(Self {
251 write_path: PathBuf::new(),
252 name: name.to_owned(),
253 next_t: RwLock::new(u64::MAX),
254 dir,
255 })
256 }
257
258 #[must_use]
260 pub fn exists(dir: impl AsRef<Path>, name: &str) -> bool {
261 !version_files(dir.as_ref(), name).is_empty()
262 }
263
264 pub fn delete_all(dir: impl AsRef<Path>, name: &str) -> Result<(), LogError> {
269 for (_, path) in version_files(dir.as_ref(), name) {
270 match fs::remove_file(&path) {
271 Ok(()) => {}
272 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
273 Err(error) => return Err(error.into()),
274 }
275 }
276 Ok(())
277 }
278}
279
280impl TransactionLog for VersionedLog {
281 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
282 let mut next_t = self.next_t.write().expect("poisoned log lock");
283 if *next_t != record.t {
284 return Err(LogError::Corrupt);
285 }
286 let payload = encode_record(record);
287 let mut file = OpenOptions::new().append(true).open(&self.write_path)?;
288 file.write_all(
289 &u64::try_from(payload.len())
290 .map_err(|_| LogError::Corrupt)?
291 .to_be_bytes(),
292 )?;
293 file.write_all(&payload)?;
294 file.sync_all()?;
295 *next_t += 1;
296 Ok(())
297 }
298
299 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
300 let _guard = self.next_t.read().expect("poisoned log lock");
301 Ok(read_merged(&self.dir, &self.name)?
302 .into_iter()
303 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
304 .collect())
305 }
306}
307
308fn merge_versions(mut per_version: Vec<Vec<TxRecord>>) -> Vec<TxRecord> {
313 let mut cutoff = u64::MAX;
314 for records in per_version.iter_mut().rev() {
315 let first = records.first().map(|r| r.t);
316 records.retain(|r| r.t < cutoff);
317 if let Some(first) = first {
318 cutoff = cutoff.min(first);
319 }
320 }
321 per_version.into_iter().flatten().collect()
322}
323
324#[async_trait]
340pub trait NativeLogStorage: Send + Sync {
341 async fn put_batch(
354 &self,
355 name: &str,
356 version: u64,
357 records: &[(u64, Vec<u8>)],
358 ) -> Result<bool, LogError>;
359 async fn read_record(
365 &self,
366 name: &str,
367 version: u64,
368 t: u64,
369 ) -> Result<Option<Vec<u8>>, LogError>;
370 async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
376 async fn read_legacy_chunk(
382 &self,
383 name: &str,
384 version: u64,
385 chunk: u64,
386 ) -> Result<Option<Vec<u8>>, LogError>;
387 async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError>;
395 async fn delete_all(&self, name: &str) -> Result<(), LogError>;
400}
401
402pub struct NativeVersionedLog<S: ?Sized> {
411 storage: Arc<S>,
412 name: String,
413 write_version: u64,
414 read_only: bool,
415 next_t: tokio::sync::Mutex<u64>,
417}
418
419impl<S: NativeLogStorage + ?Sized + 'static> NativeVersionedLog<S> {
420 pub async fn open(storage: Arc<S>, name: &str, write_version: u64) -> Result<Self, LogError> {
425 let records = read_native_merged(storage.as_ref(), name).await?;
429 let next_t = records.last().map_or(1, |r| r.t + 1);
430 Ok(Self {
431 storage,
432 name: name.to_owned(),
433 write_version,
434 read_only: false,
435 next_t: tokio::sync::Mutex::new(next_t),
436 })
437 }
438
439 #[must_use]
442 pub fn open_read_only(storage: Arc<S>, name: &str) -> Self {
443 Self {
444 storage,
445 name: name.to_owned(),
446 write_version: 0,
447 read_only: true,
448 next_t: tokio::sync::Mutex::new(0),
449 }
450 }
451}
452
453#[async_trait]
454impl<S: NativeLogStorage + ?Sized + 'static> TransactionLog for NativeVersionedLog<S> {
455 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
456 let _ = record;
457 Err(LogError::AsyncOnly)
458 }
459
460 async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
461 self.append_batch_async(std::slice::from_ref(record)).await
462 }
463
464 async fn append_batch_async(&self, records: &[TxRecord]) -> Result<(), LogError> {
465 if self.read_only {
466 return Err(LogError::Native("transaction log is read-only".into()));
467 }
468 if records.is_empty() {
469 return Ok(());
470 }
471 let mut next_t = self.next_t.lock().await;
472 for (offset, record) in records.iter().enumerate() {
474 if record.t != *next_t + offset as u64 {
475 return Err(LogError::Corrupt);
476 }
477 }
478 let framed = records
479 .iter()
480 .map(|record| {
481 let mut bytes = Vec::new();
482 append_framed_record(&mut bytes, record)?;
483 Ok((record.t, bytes))
484 })
485 .collect::<Result<Vec<_>, LogError>>()?;
486 if !self
491 .storage
492 .put_batch(&self.name, self.write_version, &framed)
493 .await?
494 {
495 return Err(LogError::Corrupt);
496 }
497 *next_t += records.len() as u64;
498 Ok(())
499 }
500
501 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
502 let _ = (start, end);
503 Err(LogError::AsyncOnly)
504 }
505
506 async fn tx_range_async(
507 &self,
508 start: u64,
509 end: Option<u64>,
510 ) -> Result<Vec<TxRecord>, LogError> {
511 let _guard = self.next_t.lock().await;
514 Ok(read_native_merged(self.storage.as_ref(), &self.name)
515 .await?
516 .into_iter()
517 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
518 .collect())
519 }
520}
521
522async fn read_native_merged<S: NativeLogStorage + ?Sized>(
523 storage: &S,
524 name: &str,
525) -> Result<Vec<TxRecord>, LogError> {
526 use std::collections::BTreeMap;
527
528 let mut per_version: BTreeMap<u64, Vec<TxRecord>> = BTreeMap::new();
531
532 let mut chunks = storage.list_legacy_chunks(name).await?;
536 chunks.sort_unstable();
537 for (version, chunk) in chunks {
538 let bytes = storage
539 .read_legacy_chunk(name, version, chunk)
540 .await?
541 .unwrap_or_default();
542 per_version
543 .entry(version)
544 .or_default()
545 .extend(decode_framed_records(&bytes)?);
546 }
547
548 let mut records = storage.list_records(name).await?;
550 records.sort_unstable();
551 for (version, t) in records {
552 let bytes = storage
553 .read_record(name, version, t)
554 .await?
555 .unwrap_or_default();
556 per_version
557 .entry(version)
558 .or_default()
559 .extend(decode_framed_records(&bytes)?);
560 }
561
562 let per_version: Vec<Vec<TxRecord>> = per_version
567 .into_values()
568 .map(|mut records| {
569 records.sort_by_key(|record| record.t);
570 records
571 })
572 .collect();
573 let merged = merge_versions(per_version);
574 for pair in merged.windows(2) {
575 if pair[1].t != pair[0].t + 1 {
576 return Err(LogError::Corrupt);
577 }
578 }
579 Ok(merged)
580}
581
582type VersionedRecords = Arc<Mutex<Vec<(u64, TxRecord)>>>;
585
586#[derive(Clone, Default)]
592pub struct MemLogRegistry {
593 logs: Arc<Mutex<HashMap<String, VersionedRecords>>>,
594}
595
596impl MemLogRegistry {
597 #[must_use]
599 pub fn new() -> Self {
600 Self::default()
601 }
602
603 fn entry(&self, name: &str) -> VersionedRecords {
604 Arc::clone(
605 self.logs
606 .lock()
607 .unwrap_or_else(std::sync::PoisonError::into_inner)
608 .entry(name.to_owned())
609 .or_default(),
610 )
611 }
612
613 #[must_use]
616 pub fn open(&self, name: &str, write_version: u64) -> MemVersionedLog {
617 let records = self.entry(name);
618 let next_t = {
619 let guard = records
620 .lock()
621 .unwrap_or_else(std::sync::PoisonError::into_inner);
622 MemVersionedLog::merged(&guard)
623 .last()
624 .map_or(1, |r| r.t + 1)
625 };
626 MemVersionedLog {
627 records,
628 write_version,
629 next_t: Mutex::new(next_t),
630 }
631 }
632
633 #[must_use]
635 pub fn exists(&self, name: &str) -> bool {
636 self.logs
637 .lock()
638 .unwrap_or_else(std::sync::PoisonError::into_inner)
639 .get(name)
640 .is_some_and(|entry| {
641 !entry
642 .lock()
643 .unwrap_or_else(std::sync::PoisonError::into_inner)
644 .is_empty()
645 })
646 }
647
648 pub fn delete_all(&self, name: &str) {
650 self.logs
651 .lock()
652 .unwrap_or_else(std::sync::PoisonError::into_inner)
653 .remove(name);
654 }
655}
656
657pub struct MemVersionedLog {
661 records: VersionedRecords,
662 write_version: u64,
663 next_t: Mutex<u64>,
667}
668
669impl MemVersionedLog {
670 fn merged(records: &[(u64, TxRecord)]) -> Vec<TxRecord> {
671 let mut versions: Vec<u64> = records.iter().map(|(version, _)| *version).collect();
672 versions.sort_unstable();
673 versions.dedup();
674 let per_version = versions
675 .into_iter()
676 .map(|version| {
677 records
678 .iter()
679 .filter(|(record_version, _)| *record_version == version)
680 .map(|(_, record)| record.clone())
681 .collect::<Vec<_>>()
682 })
683 .collect();
684 merge_versions(per_version)
685 }
686}
687
688impl TransactionLog for MemVersionedLog {
689 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
690 let mut next_t = self
691 .next_t
692 .lock()
693 .unwrap_or_else(std::sync::PoisonError::into_inner);
694 if *next_t != record.t {
695 return Err(LogError::Corrupt);
696 }
697 self.records
698 .lock()
699 .unwrap_or_else(std::sync::PoisonError::into_inner)
700 .push((self.write_version, record.clone()));
701 *next_t += 1;
702 Ok(())
703 }
704
705 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
706 let records = self
707 .records
708 .lock()
709 .unwrap_or_else(std::sync::PoisonError::into_inner);
710 Ok(Self::merged(&records)
711 .into_iter()
712 .filter(|r| r.t >= start && end.is_none_or(|e| r.t < e))
713 .collect())
714 }
715}
716
717fn version_path(dir: &Path, name: &str, version: u64) -> PathBuf {
718 if version == 0 {
719 dir.join(format!("{name}.log"))
720 } else {
721 dir.join(format!("{name}.v{version}.log"))
722 }
723}
724
725fn version_files(dir: &Path, name: &str) -> Vec<(u64, PathBuf)> {
727 let mut files = Vec::new();
728 let legacy = version_path(dir, name, 0);
729 if legacy.is_file() {
730 files.push((0, legacy));
731 }
732 let prefix = format!("{name}.v");
733 if let Ok(entries) = fs::read_dir(dir) {
734 for entry in entries.flatten() {
735 let file_name = entry.file_name();
736 let Some(text) = file_name.to_str() else {
737 continue;
738 };
739 if let Some(version) = text
740 .strip_prefix(&prefix)
741 .and_then(|rest| rest.strip_suffix(".log"))
742 .and_then(|v| v.parse::<u64>().ok())
743 && version > 0
744 {
745 files.push((version, entry.path()));
746 }
747 }
748 }
749 files.sort_by_key(|(version, _)| *version);
750 files
751}
752
753fn read_merged(dir: &Path, name: &str) -> Result<Vec<TxRecord>, LogError> {
756 let files = version_files(dir, name);
757 let mut per_file: Vec<Vec<TxRecord>> = Vec::with_capacity(files.len());
758 for (_, path) in &files {
759 per_file.push(read_records(path)?.0);
760 }
761 let merged = merge_versions(per_file);
766 for pair in merged.windows(2) {
767 if pair[1].t != pair[0].t + 1 {
768 return Err(LogError::Corrupt);
769 }
770 }
771 Ok(merged)
772}
773
774fn encode_record(record: &TxRecord) -> Vec<u8> {
775 let mut out = Vec::new();
776 out.extend_from_slice(&record.t.to_be_bytes());
777 out.extend_from_slice(&record.tx_instant.to_be_bytes());
778 out.extend_from_slice(&(record.datoms.len() as u64).to_be_bytes());
779 for d in &record.datoms {
780 out.extend_from_slice(&d.e.raw().to_be_bytes());
781 out.extend_from_slice(&d.a.raw().to_be_bytes());
782 out.extend_from_slice(&d.tx.raw().to_be_bytes());
783 out.push(u8::from(d.added));
784 let v = encode_value(&d.v);
785 out.extend_from_slice(&(v.len() as u64).to_be_bytes());
786 out.extend_from_slice(&v);
787 }
788 out
789}
790fn decode_record(mut bytes: &[u8]) -> Result<TxRecord, LogError> {
791 fn take<'a>(bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], LogError> {
792 let value = bytes.get(..n).ok_or(LogError::Corrupt)?;
793 *bytes = &bytes[n..];
794 Ok(value)
795 }
796 fn u64_be(bytes: &mut &[u8]) -> Result<u64, LogError> {
797 Ok(u64::from_be_bytes(
798 take(bytes, 8)?.try_into().map_err(|_| LogError::Corrupt)?,
799 ))
800 }
801 let t = u64_be(&mut bytes)?;
802 let tx_instant = i64::from_be_bytes(
803 take(&mut bytes, 8)?
804 .try_into()
805 .map_err(|_| LogError::Corrupt)?,
806 );
807 let count = u64_be(&mut bytes)?;
808 let mut datoms = Vec::new();
809 for _ in 0..count {
810 let e = EntityId::from_raw(u64_be(&mut bytes)?);
811 let a = EntityId::from_raw(u64_be(&mut bytes)?);
812 let tx = EntityId::from_raw(u64_be(&mut bytes)?);
813 let added = take(&mut bytes, 1)?[0] != 0;
814 let len = usize::try_from(u64_be(&mut bytes)?).map_err(|_| LogError::Corrupt)?;
815 let raw = take(&mut bytes, len)?;
816 let (v, used) = decode_value(raw).map_err(|_| LogError::Corrupt)?;
817 if used != len {
818 return Err(LogError::Corrupt);
819 }
820 datoms.push(Datom { e, a, v, tx, added });
821 }
822 if !bytes.is_empty() {
823 return Err(LogError::Corrupt);
824 }
825 Ok(TxRecord {
826 t,
827 tx_instant,
828 datoms,
829 })
830}
831fn read_records(path: &Path) -> Result<(Vec<TxRecord>, u64), LogError> {
837 let mut file = File::open(path)?;
838 let mut records = Vec::new();
839 let mut durable_len = 0_u64;
840 loop {
841 let mut len = [0; 8];
842 match file.read_exact(&mut len) {
843 Ok(()) => {}
844 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
845 Err(e) => return Err(e.into()),
846 }
847 let len = usize::try_from(u64::from_be_bytes(len)).map_err(|_| LogError::Corrupt)?;
848 let mut payload = vec![0; len];
849 match file.read_exact(&mut payload) {
850 Ok(()) => {}
851 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
852 Err(e) => return Err(e.into()),
853 }
854 records.push(decode_record(&payload)?);
855 durable_len += 8 + len as u64;
856 }
857 Ok((records, durable_len))
858}
859
860pub fn append_framed_record(out: &mut Vec<u8>, record: &TxRecord) -> Result<(), LogError> {
865 let payload = encode_record(record);
866 out.extend_from_slice(
867 &u64::try_from(payload.len())
868 .map_err(|_| LogError::Corrupt)?
869 .to_be_bytes(),
870 );
871 out.extend_from_slice(&payload);
872 Ok(())
873}
874
875pub fn decode_framed_records(mut bytes: &[u8]) -> Result<Vec<TxRecord>, LogError> {
884 let mut records = Vec::new();
885 while !bytes.is_empty() {
886 if bytes.len() < 8 {
887 return Err(LogError::Corrupt);
888 }
889 let len = usize::try_from(u64::from_be_bytes(
890 bytes[..8].try_into().map_err(|_| LogError::Corrupt)?,
891 ))
892 .map_err(|_| LogError::Corrupt)?;
893 bytes = &bytes[8..];
894 let payload = bytes.get(..len).ok_or(LogError::Corrupt)?;
895 records.push(decode_record(payload)?);
896 bytes = &bytes[len..];
897 }
898 Ok(records)
899}